From 853943aba1c68cb271f49f2854af694839bcfee0 Mon Sep 17 00:00:00 2001 From: bwees Date: Fri, 21 Nov 2025 22:50:41 -0600 Subject: [PATCH] feat: asset dimensions in asset table --- mobile/lib/domain/services/asset.service.dart | 23 +-- .../repositories/sync_stream.repository.dart | 18 +- .../openapi/lib/model/asset_response_dto.dart | 34 +++- mobile/openapi/lib/model/sync_asset_v1.dart | 30 ++- .../sync_stream_repository_test.dart | 185 ++++++++++++++++++ .../domain/services/asset.service_test.dart | 84 +------- mobile/test/fixtures/sync_stream.stub.dart | 22 +-- .../modules/utils/openapi_patching_test.dart | 12 ++ open-api/immich-openapi-specs.json | 24 ++- open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/database.ts | 4 + server/src/dtos/asset-response.dto.ts | 8 + server/src/dtos/sync.dto.ts | 4 + server/src/queries/asset.repository.sql | 10 +- server/src/queries/sync.repository.sql | 14 +- server/src/repositories/asset.repository.ts | 6 +- .../1763785815996-AddAssetWidthHeight.ts | 28 +++ server/src/schema/tables/asset.table.ts | 6 + server/src/services/job.service.ts | 2 + server/src/services/metadata.service.spec.ts | 53 +++++ server/src/services/metadata.service.ts | 35 +++- server/test/fixtures/asset.stub.ts | 48 ++++- server/test/fixtures/shared-link.stub.ts | 6 + .../specs/sync/sync-album-asset.spec.ts | 4 + .../test/medium/specs/sync/sync-asset.spec.ts | 4 + .../specs/sync/sync-partner-asset.spec.ts | 2 + server/test/small.factory.ts | 2 + web/src/test-data/factories/asset-factory.ts | 2 + 28 files changed, 529 insertions(+), 143 deletions(-) create mode 100644 mobile/test/domain/repositories/sync_stream_repository_test.dart create mode 100644 server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 3d8fddc9b7..aca0c3394e 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -1,10 +1,8 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; -import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; class AssetService { final RemoteAssetRepository _remoteAssetRepository; @@ -58,22 +56,11 @@ class AssetService { } Future getAspectRatio(BaseAsset asset) async { - bool isFlipped; double? width; double? height; - if (asset.hasRemote) { - final exif = await getExif(asset); - isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation); - width = asset.width?.toDouble(); - height = asset.height?.toDouble(); - } else if (asset is LocalAsset) { - isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270); - width = asset.width?.toDouble(); - height = asset.height?.toDouble(); - } else { - isFlipped = false; - } + width = asset.width?.toDouble(); + height = asset.height?.toDouble(); if (width == null || height == null) { if (asset.hasRemote) { @@ -89,10 +76,8 @@ class AssetService { } } - final orientedWidth = isFlipped ? height : width; - final orientedHeight = isFlipped ? width : height; - if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) { - return orientedWidth / orientedHeight; + if (width != null && height != null && height > 0) { + return width / height; } return 1.0; diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index 5ab1844571..b6dc7a2868 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -22,6 +22,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey; import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey; @@ -194,6 +195,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { livePhotoVideoId: Value(asset.livePhotoVideoId), stackId: Value(asset.stackId), libraryId: Value(asset.libraryId), + width: Value(asset.width), + height: Value(asset.height), ); batch.insert( @@ -245,10 +248,21 @@ class SyncStreamRepository extends DriftDatabaseRepository { await _db.batch((batch) { for (final exif in data) { + int? width; + int? height; + + if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) { + width = exif.exifImageHeight; + height = exif.exifImageWidth; + } else { + width = exif.exifImageWidth; + height = exif.exifImageHeight; + } + batch.update( _db.remoteAssetEntity, - RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)), - where: (row) => row.id.equals(exif.assetId), + RemoteAssetEntityCompanion(width: Value(width), height: Value(height)), + where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(), ); } }); diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 8d49986359..c9581b19dd 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -23,6 +23,7 @@ class AssetResponseDto { required this.fileCreatedAt, required this.fileModifiedAt, required this.hasMetadata, + required this.height, required this.id, required this.isArchived, required this.isFavorite, @@ -45,6 +46,7 @@ class AssetResponseDto { this.unassignedFaces = const [], required this.updatedAt, required this.visibility, + required this.width, }); /// base64 encoded sha1 hash @@ -77,6 +79,8 @@ class AssetResponseDto { bool hasMetadata; + num? height; + String id; bool isArchived; @@ -141,6 +145,8 @@ class AssetResponseDto { AssetVisibility visibility; + num? width; + @override bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && other.checksum == checksum && @@ -153,6 +159,7 @@ class AssetResponseDto { other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && other.hasMetadata == hasMetadata && + other.height == height && other.id == id && other.isArchived == isArchived && other.isFavorite == isFavorite && @@ -174,7 +181,8 @@ class AssetResponseDto { other.type == type && _deepEquality.equals(other.unassignedFaces, unassignedFaces) && other.updatedAt == updatedAt && - other.visibility == visibility; + other.visibility == visibility && + other.width == width; @override int get hashCode => @@ -189,6 +197,7 @@ class AssetResponseDto { (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + (hasMetadata.hashCode) + + (height == null ? 0 : height!.hashCode) + (id.hashCode) + (isArchived.hashCode) + (isFavorite.hashCode) + @@ -210,10 +219,11 @@ class AssetResponseDto { (type.hashCode) + (unassignedFaces.hashCode) + (updatedAt.hashCode) + - (visibility.hashCode); + (visibility.hashCode) + + (width == null ? 0 : width!.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, 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, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]'; + String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, 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, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -235,6 +245,11 @@ class AssetResponseDto { json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String(); json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); json[r'hasMetadata'] = this.hasMetadata; + if (this.height != null) { + json[r'height'] = this.height; + } else { + // json[r'height'] = null; + } json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; json[r'isFavorite'] = this.isFavorite; @@ -285,6 +300,11 @@ class AssetResponseDto { json[r'unassignedFaces'] = this.unassignedFaces; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'visibility'] = this.visibility; + if (this.width != null) { + json[r'width'] = this.width; + } else { + // json[r'width'] = null; + } return json; } @@ -307,6 +327,9 @@ class AssetResponseDto { fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, hasMetadata: mapValueOfType(json, r'hasMetadata')!, + height: json[r'height'] == null + ? null + : num.parse('${json[r'height']}'), id: mapValueOfType(json, r'id')!, isArchived: mapValueOfType(json, r'isArchived')!, isFavorite: mapValueOfType(json, r'isFavorite')!, @@ -329,6 +352,9 @@ class AssetResponseDto { unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, + width: json[r'width'] == null + ? null + : num.parse('${json[r'width']}'), ); } return null; @@ -384,6 +410,7 @@ class AssetResponseDto { 'fileCreatedAt', 'fileModifiedAt', 'hasMetadata', + 'height', 'id', 'isArchived', 'isFavorite', @@ -397,6 +424,7 @@ class AssetResponseDto { 'type', 'updatedAt', 'visibility', + 'width', }; } diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index f0d5097ea4..a2c89eb5c1 100644 --- a/mobile/openapi/lib/model/sync_asset_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -18,6 +18,7 @@ class SyncAssetV1 { required this.duration, required this.fileCreatedAt, required this.fileModifiedAt, + required this.height, required this.id, required this.isFavorite, required this.libraryId, @@ -29,6 +30,7 @@ class SyncAssetV1 { required this.thumbhash, required this.type, required this.visibility, + required this.width, }); String checksum; @@ -41,6 +43,8 @@ class SyncAssetV1 { DateTime? fileModifiedAt; + int? height; + String id; bool isFavorite; @@ -63,6 +67,8 @@ class SyncAssetV1 { AssetVisibility visibility; + int? width; + @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 && other.checksum == checksum && @@ -70,6 +76,7 @@ class SyncAssetV1 { other.duration == duration && other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && + other.height == height && other.id == id && other.isFavorite == isFavorite && other.libraryId == libraryId && @@ -80,7 +87,8 @@ class SyncAssetV1 { other.stackId == stackId && other.thumbhash == thumbhash && other.type == type && - other.visibility == visibility; + other.visibility == visibility && + other.width == width; @override int get hashCode => @@ -90,6 +98,7 @@ class SyncAssetV1 { (duration == null ? 0 : duration!.hashCode) + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + + (height == null ? 0 : height!.hashCode) + (id.hashCode) + (isFavorite.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) + @@ -100,10 +109,11 @@ class SyncAssetV1 { (stackId == null ? 0 : stackId!.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + (type.hashCode) + - (visibility.hashCode); + (visibility.hashCode) + + (width == null ? 0 : width!.hashCode); @override - String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]'; + String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -127,6 +137,11 @@ class SyncAssetV1 { json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String(); } else { // json[r'fileModifiedAt'] = null; + } + if (this.height != null) { + json[r'height'] = this.height; + } else { + // json[r'height'] = null; } json[r'id'] = this.id; json[r'isFavorite'] = this.isFavorite; @@ -159,6 +174,11 @@ class SyncAssetV1 { } json[r'type'] = this.type; json[r'visibility'] = this.visibility; + if (this.width != null) { + json[r'width'] = this.width; + } else { + // json[r'width'] = null; + } return json; } @@ -176,6 +196,7 @@ class SyncAssetV1 { duration: mapValueOfType(json, r'duration'), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''), fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), + height: mapValueOfType(json, r'height'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite')!, libraryId: mapValueOfType(json, r'libraryId'), @@ -187,6 +208,7 @@ class SyncAssetV1 { thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, visibility: AssetVisibility.fromJson(json[r'visibility'])!, + width: mapValueOfType(json, r'width'), ); } return null; @@ -239,6 +261,7 @@ class SyncAssetV1 { 'duration', 'fileCreatedAt', 'fileModifiedAt', + 'height', 'id', 'isFavorite', 'libraryId', @@ -250,6 +273,7 @@ class SyncAssetV1 { 'thumbhash', 'type', 'visibility', + 'width', }; } diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart new file mode 100644 index 0000000000..d39446ada3 --- /dev/null +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -0,0 +1,185 @@ +import 'package:drift/drift.dart' as drift; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:openapi/api.dart'; + +SyncUserV1 _createUser({String id = 'user-1'}) { + return SyncUserV1( + id: id, + name: 'Test User', + email: 'test@test.com', + deletedAt: null, + avatarColor: null, + hasProfileImage: false, + profileChangedAt: DateTime(2024, 1, 1), + ); +} + +SyncAssetV1 _createAsset({ + required String id, + required String checksum, + required String fileName, + String ownerId = 'user-1', + int? width, + int? height, +}) { + return SyncAssetV1( + id: id, + checksum: checksum, + originalFileName: fileName, + type: AssetTypeEnum.IMAGE, + ownerId: ownerId, + isFavorite: false, + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), + localDateTime: DateTime(2024, 1, 1), + visibility: AssetVisibility.timeline, + width: width, + height: height, + deletedAt: null, + duration: null, + libraryId: null, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, + ); +} + +SyncAssetExifV1 _createExif({ + required String assetId, + required int width, + required int height, + required String orientation, +}) { + return SyncAssetExifV1( + assetId: assetId, + exifImageWidth: width, + exifImageHeight: height, + orientation: orientation, + city: null, + country: null, + dateTimeOriginal: null, + description: null, + exposureTime: null, + fNumber: null, + fileSizeInByte: null, + focalLength: null, + fps: null, + iso: null, + latitude: null, + lensModel: null, + longitude: null, + make: null, + model: null, + modifyDate: null, + profileDescription: null, + projectionType: null, + rating: null, + state: null, + timeZone: null, + ); +} + +void main() { + late Drift db; + late SyncStreamRepository sut; + + setUp(() async { + db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + sut = SyncStreamRepository(db); + }); + + tearDown(() async { + await db.close(); + }); + + group('SyncStreamRepository - Dimension swapping based on orientation', () { + test('swaps dimensions for asset with rotated orientation', () async { + final flippedOrientations = ['5', '6', '7', '8', '90', '-90']; + + for (final orientation in flippedOrientations) { + final assetId = 'asset-$orientation-degrees'; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset( + id: assetId, + checksum: 'checksum-$orientation', + fileName: 'rotated_$orientation.jpg', + ); + await sut.updateAssetsV1([asset]); + + final exif = _createExif( + assetId: assetId, + width: 1920, + height: 1080, + orientation: orientation, // EXIF orientation value for 90 degrees CW + ); + await sut.updateAssetsExifV1([exif]); + + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(1080)); + expect(result.height, equals(1920)); + } + }); + + test('does not swap dimensions for asset with normal orientation', () async { + final nonFlippedOrientations = ['1', '2', '3', '4']; + for (final orientation in nonFlippedOrientations) { + final assetId = 'asset-$orientation-degrees'; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg'); + await sut.updateAssetsV1([asset]); + + final exif = _createExif( + assetId: assetId, + width: 1920, + height: 1080, + orientation: orientation, // EXIF orientation value for normal + ); + await sut.updateAssetsExifV1([exif]); + + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(1920)); + expect(result.height, equals(1080)); + } + }); + + test('does not update dimensions if asset already has width and height', () async { + const assetId = 'asset-with-dimensions'; + const existingWidth = 1920; + const existingHeight = 1080; + const exifWidth = 3840; + const exifHeight = 2160; + + await sut.updateUsersV1([_createUser()]); + + final asset = _createAsset( + id: assetId, + checksum: 'checksum-with-dims', + fileName: 'with_dimensions.jpg', + width: existingWidth, + height: existingHeight, + ); + await sut.updateAssetsV1([asset]); + + final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6'); + await sut.updateAssetsExifV1([exif]); + + // Verify the asset still has original dimensions (not updated from EXIF) + final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId)); + final result = await query.getSingle(); + + expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set'); + expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set'); + }); + }); +} diff --git a/mobile/test/domain/services/asset.service_test.dart b/mobile/test/domain/services/asset.service_test.dart index 5e7179ffa6..e4c7dbaec7 100644 --- a/mobile/test/domain/services/asset.service_test.dart +++ b/mobile/test/domain/services/asset.service_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; @@ -22,42 +21,6 @@ void main() { }); group('getAspectRatio', () { - test('flips dimensions on Android for 90° and 270° orientations', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - - for (final orientation in [90, 270]) { - final localAsset = TestUtils.createLocalAsset( - id: 'local-$orientation', - width: 1920, - height: 1080, - orientation: orientation, - ); - - final result = await sut.getAspectRatio(localAsset); - - expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android'); - } - }); - - test('does not flip dimensions on iOS regardless of orientation', () async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - - for (final orientation in [0, 90, 270]) { - final localAsset = TestUtils.createLocalAsset( - id: 'local-$orientation', - width: 1920, - height: 1080, - orientation: orientation, - ); - - final result = await sut.getAspectRatio(localAsset); - - expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions'); - } - }); - test('fetches dimensions from remote repository when missing from asset', () async { final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null); @@ -112,54 +75,23 @@ void main() { expect(result, 1.0); }); - test('handles local asset with remoteId and uses exif from remote', () async { + test('handles local asset with remoteId and uses remote dimensions', () async { final localAsset = TestUtils.createLocalAsset( id: 'local-1', remoteId: 'remote-1', - width: 1920, - height: 1080, + width: null, + height: null, orientation: 0, ); - final exif = const ExifInfo(orientation: '6'); - - when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); + when( + () => mockRemoteAssetRepository.get('remote-1'), + ).thenAnswer((_) async => TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080)); final result = await sut.getAspectRatio(localAsset); + verify(() => mockRemoteAssetRepository.get('remote-1')).called(1); - expect(result, 1080 / 1920); - }); - - test('handles various flipped EXIF orientations correctly', () async { - final flippedOrientations = ['5', '6', '7', '8', '90', '-90']; - - for (final orientation in flippedOrientations) { - final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); - - final exif = ExifInfo(orientation: orientation); - - when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif); - - final result = await sut.getAspectRatio(remoteAsset); - - expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions'); - } - }); - - test('handles various non-flipped EXIF orientations correctly', () async { - final nonFlippedOrientations = ['1', '2', '3', '4']; - - for (final orientation in nonFlippedOrientations) { - final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080); - - final exif = ExifInfo(orientation: orientation); - - when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif); - - final result = await sut.getAspectRatio(remoteAsset); - - expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions'); - } + expect(result, 1920 / 1080); }); }); } diff --git a/mobile/test/fixtures/sync_stream.stub.dart b/mobile/test/fixtures/sync_stream.stub.dart index 523984f966..69f6c1753f 100644 --- a/mobile/test/fixtures/sync_stream.stub.dart +++ b/mobile/test/fixtures/sync_stream.stub.dart @@ -94,25 +94,11 @@ abstract final class SyncStreamStub { required String ack, DateTime? trashedAt, }) { - return _assetV1( - id: id, - checksum: checksum, - deletedAt: trashedAt ?? DateTime(2025, 1, 1), - ack: ack, - ); + return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack); } - static SyncEvent assetModified({ - required String id, - required String checksum, - required String ack, - }) { - return _assetV1( - id: id, - checksum: checksum, - deletedAt: null, - ack: ack, - ); + static SyncEvent assetModified({required String id, required String checksum, required String ack}) { + return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack); } static SyncEvent _assetV1({ @@ -140,6 +126,8 @@ abstract final class SyncStreamStub { thumbhash: null, type: AssetTypeEnum.IMAGE, visibility: AssetVisibility.timeline, + width: null, + height: null, ), ack: ack, ); diff --git a/mobile/test/modules/utils/openapi_patching_test.dart b/mobile/test/modules/utils/openapi_patching_test.dart index b956c4bfb9..a577b0544f 100644 --- a/mobile/test/modules/utils/openapi_patching_test.dart +++ b/mobile/test/modules/utils/openapi_patching_test.dart @@ -45,5 +45,17 @@ void main() { addDefault(value, keys, defaultValue); expect(value['alpha']['beta'], 'gamma'); }); + + test('addDefault with null', () { + dynamic value = jsonDecode(""" +{ + "download": { + "archiveSize": 4294967296, + "includeEmbeddedVideos": false + } +} +"""); + expect(value['download']['unknownKey'], isNull); + }); }); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e21cf27beb..748ad4f551 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -15706,6 +15706,10 @@ "hasMetadata": { "type": "boolean" }, + "height": { + "nullable": true, + "type": "number" + }, "id": { "type": "string" }, @@ -15826,6 +15830,10 @@ "$ref": "#/components/schemas/AssetVisibility" } ] + }, + "width": { + "nullable": true, + "type": "number" } }, "required": [ @@ -15837,6 +15845,7 @@ "fileCreatedAt", "fileModifiedAt", "hasMetadata", + "height", "id", "isArchived", "isFavorite", @@ -15849,7 +15858,8 @@ "thumbhash", "type", "updatedAt", - "visibility" + "visibility", + "width" ], "type": "object" }, @@ -20624,6 +20634,10 @@ "nullable": true, "type": "string" }, + "height": { + "nullable": true, + "type": "integer" + }, "id": { "type": "string" }, @@ -20670,6 +20684,10 @@ "$ref": "#/components/schemas/AssetVisibility" } ] + }, + "width": { + "nullable": true, + "type": "integer" } }, "required": [ @@ -20678,6 +20696,7 @@ "duration", "fileCreatedAt", "fileModifiedAt", + "height", "id", "isFavorite", "libraryId", @@ -20688,7 +20707,8 @@ "stackId", "thumbhash", "type", - "visibility" + "visibility", + "width" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7afee42e2c..d2e6473045 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -349,6 +349,7 @@ export type AssetResponseDto = { /** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */ fileModifiedAt: string; hasMetadata: boolean; + height: number | null; id: string; isArchived: boolean; isFavorite: boolean; @@ -373,6 +374,7 @@ export type AssetResponseDto = { /** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */ updatedAt: string; visibility: AssetVisibility; + width: number | null; }; export type ContributorCountResponseDto = { assetCount: number; diff --git a/server/src/database.ts b/server/src/database.ts index a3c38ae61e..854a082559 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -340,6 +340,8 @@ export const columns = { 'asset.originalPath', 'asset.ownerId', 'asset.type', + 'asset.width', + 'asset.height', ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], @@ -390,6 +392,8 @@ export const columns = { 'asset.livePhotoVideoId', 'asset.stackId', 'asset.libraryId', + 'asset.width', + 'asset.height', ], syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'], syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'], diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index e228cd8f9f..63f3643a4b 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -34,6 +34,8 @@ export class SanitizedAssetResponseDto { duration!: string; livePhotoVideoId?: string | null; hasMetadata!: boolean; + width!: number | null; + height!: number | null; } export class AssetResponseDto extends SanitizedAssetResponseDto { @@ -129,6 +131,8 @@ export type MapAsset = { tags?: Tag[]; thumbhash: Buffer | null; type: AssetType; + width: number | null; + height: number | null; }; export class AssetStackResponseDto { @@ -190,6 +194,8 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset duration: entity.duration ?? '0:00:00.00000', livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, + width: entity.width, + height: entity.height, }; return sanitizedAssetResponse as AssetResponseDto; } @@ -227,5 +233,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset hasMetadata: true, duplicateId: entity.duplicateId, resized: true, + width: entity.width, + height: entity.height, }; } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d6a557e2c5..7979bbfd40 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -118,6 +118,10 @@ export class SyncAssetV1 { livePhotoVideoId!: string | null; stackId!: string | null; libraryId!: string | null; + @ApiProperty({ type: 'integer' }) + width!: number | null; + @ApiProperty({ type: 'integer' }) + height!: number | null; } @ExtraModel() diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 01cc6a7a89..a0c2edf581 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -345,14 +345,10 @@ with "asset_exif"."projectionType", coalesce( case - when asset_exif."exifImageHeight" = 0 - or asset_exif."exifImageWidth" = 0 then 1 - when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round( - asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, - 3 - ) + when asset."height" = 0 + or asset."width" = 0 then 1 else round( - asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, + asset."width"::numeric / asset."height"::numeric, 3 ) end, diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 7c1dc3b6b4..1c88864e12 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -69,6 +69,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "album_asset"."updateId" from "album_asset" as "album_asset" @@ -99,6 +101,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -134,7 +138,9 @@ select "asset"."duration", "asset"."livePhotoVideoId", "asset"."stackId", - "asset"."libraryId" + "asset"."libraryId", + "asset"."width", + "asset"."height" from "album_asset" as "album_asset" inner join "asset" on "asset"."id" = "album_asset"."assetId" @@ -448,6 +454,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -740,6 +748,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" @@ -789,6 +799,8 @@ select "asset"."livePhotoVideoId", "asset"."stackId", "asset"."libraryId", + "asset"."width", + "asset"."height", "asset"."updateId" from "asset" as "asset" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 4b8cbd7a7a..fd109f3356 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -632,11 +632,9 @@ export class AssetRepository { .coalesce( eb .case() - .when(sql`asset_exif."exifImageHeight" = 0 or asset_exif."exifImageWidth" = 0`) + .when(sql`asset."height" = 0 or asset."width" = 0`) .then(eb.lit(1)) - .when('asset_exif.orientation', 'in', sql`('5', '6', '7', '8', '-90', '90')`) - .then(sql`round(asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, 3)`) - .else(sql`round(asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, 3)`) + .else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`) .end(), eb.lit(1), ) diff --git a/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts b/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts new file mode 100644 index 0000000000..90ae32bebf --- /dev/null +++ b/server/src/schema/migrations/1763785815996-AddAssetWidthHeight.ts @@ -0,0 +1,28 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset" ADD COLUMN "width" integer;`.execute(db); + await sql`ALTER TABLE "asset" ADD COLUMN "height" integer;`.execute(db); + + // Populate width and height from exif data with orientation-aware swapping + await sql` + UPDATE "asset" + SET + "width" = CASE + WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageHeight" + ELSE "asset_exif"."exifImageWidth" + END, + "height" = CASE + WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageWidth" + ELSE "asset_exif"."exifImageHeight" + END + FROM "asset_exif" + WHERE "asset"."id" = "asset_exif"."assetId" + AND ("asset_exif"."exifImageWidth" IS NOT NULL OR "asset_exif"."exifImageHeight" IS NOT NULL) + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "asset" DROP COLUMN "width";`.execute(db); + await sql`ALTER TABLE "asset" DROP COLUMN "height";`.execute(db); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index b28fc99e4a..96ea0a98d8 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -137,4 +137,10 @@ export class AssetTable { @Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline }) visibility!: Generated; + + @Column({ type: 'integer', nullable: true }) + width!: number | null; + + @Column({ type: 'integer', nullable: true }) + height!: number | null; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index b57a203788..060b3597ef 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -141,6 +141,8 @@ export class JobService extends BaseService { livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, exif: { assetId: exif.assetId, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 46d6fe7abc..5b907d2fa8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -221,6 +221,8 @@ describe(MetadataService.name, () => { fileCreatedAt: fileModifiedAt, fileModifiedAt, localDateTime: fileModifiedAt, + width: null, + height: null, }); }); @@ -245,6 +247,8 @@ describe(MetadataService.name, () => { fileCreatedAt, fileModifiedAt, localDateTime: fileCreatedAt, + width: null, + height: null, }); }); @@ -288,6 +292,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.image.fileCreatedAt, fileModifiedAt: assetStub.image.fileCreatedAt, localDateTime: assetStub.image.fileCreatedAt, + width: null, + height: null, }); }); @@ -317,6 +323,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.withLocation.fileCreatedAt, fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), + width: null, + height: null, }); }); @@ -346,6 +354,8 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.withLocation.fileCreatedAt, fileModifiedAt: assetStub.withLocation.fileModifiedAt, localDateTime: new Date('2023-02-22T05:06:29.716Z'), + width: null, + height: null, }); }); @@ -1517,6 +1527,49 @@ describe(MetadataService.name, () => { }), ); }); + + it('should properly set width/height for normal images', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 1000, + height: 2000, + }), + ); + }); + + it('should properly swap asset width/height for rotated images', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); + mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).toHaveBeenCalledWith( + expect.objectContaining({ + width: 2000, + height: 1000, + }), + ); + }); + + it('should not overwrite existing width/height if they already exist', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ + ...assetStub.image, + width: 1920, + height: 1080, + }); + mockReadTags({ ImageWidth: 1280, ImageHeight: 720 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(mocks.asset.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + width: 1280, + height: 720, + }), + ); + }); }); describe('handleQueueSidecar', () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 4d6d4c190f..a47934c30f 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -195,6 +195,15 @@ export class MetadataService extends BaseService { await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); } + private isOrientationSidewards(orientation: ExifOrientation | number): boolean { + return [ + ExifOrientation.MirrorHorizontalRotate270CW, + ExifOrientation.Rotate90CW, + ExifOrientation.MirrorHorizontalRotate90CW, + ExifOrientation.Rotate270CW, + ].includes(orientation); + } + @OnJob({ name: JobName.AssetExtractMetadataQueueAll, queue: QueueName.MetadataExtraction }) async handleQueueMetadataExtraction(job: JobOf): Promise { const { force } = job; @@ -288,6 +297,10 @@ export class MetadataService extends BaseService { autoStackId: this.getAutoStackId(exifTags), }; + const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation); + const assetWidth = isSidewards ? validate(height) : validate(width); + const assetHeight = isSidewards ? validate(width) : validate(height); + const promises: Promise[] = [ this.assetRepository.upsertExif(exifData), this.assetRepository.update({ @@ -296,6 +309,11 @@ export class MetadataService extends BaseService { localDateTime: dates.localDateTime, fileCreatedAt: dates.dateTimeOriginal ?? undefined, fileModifiedAt: stats.mtime, + + // only update the dimensions if they don't already exist + // we don't want to overwrite width/height that are modified by edits + width: asset.width == null ? assetWidth : undefined, + height: asset.height == null ? assetHeight : undefined, }), this.applyTagList(asset, exifTags), ]; @@ -698,12 +716,7 @@ export class MetadataService extends BaseService { return regionInfo; } - const isSidewards = [ - ExifOrientation.MirrorHorizontalRotate270CW, - ExifOrientation.Rotate90CW, - ExifOrientation.MirrorHorizontalRotate90CW, - ExifOrientation.Rotate270CW, - ].includes(orientation); + const isSidewards = this.isOrientationSidewards(orientation); // swap image dimensions in AppliedToDimensions if orientation is sidewards const adjustedAppliedToDimensions = isSidewards @@ -949,9 +962,17 @@ export class MetadataService extends BaseService { private async getVideoTags(originalPath: string) { const { videoStreams, format } = await this.mediaRepository.probe(originalPath); - const tags: Pick = {}; + const tags: Pick = {}; if (videoStreams[0]) { + // Set video dimensions + if (videoStreams[0].width) { + tags.ImageWidth = videoStreams[0].width; + } + if (videoStreams[0].height) { + tags.ImageHeight = videoStreams[0].height; + } + switch (videoStreams[0].rotation) { case -90: { tags.Orientation = ExifOrientation.Rotate90CW; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index f5935d5d0e..0abcb8d220 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -101,6 +101,8 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, }), noWebpPath: Object.freeze({ @@ -139,6 +141,8 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, }), noThumbhash: Object.freeze({ @@ -174,6 +178,8 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, }), primaryImage: Object.freeze({ @@ -219,6 +225,8 @@ export const assetStub = { updateId: '42', libraryId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), image: Object.freeze({ @@ -261,8 +269,8 @@ export const assetStub = { stack: null, orientation: '', projectionType: null, - height: 3840, - width: 2160, + height: null, + width: null, visibility: AssetVisibility.Timeline, }), @@ -304,6 +312,8 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, }), trashedOffline: Object.freeze({ @@ -344,6 +354,8 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, }), archived: Object.freeze({ id: 'asset-id', @@ -383,6 +395,8 @@ export const assetStub = { stackId: null, updateId: '42', visibility: AssetVisibility.Timeline, + width: null, + height: null, }), external: Object.freeze({ @@ -422,6 +436,8 @@ export const assetStub = { stackId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), image1: Object.freeze({ @@ -461,6 +477,8 @@ export const assetStub = { libraryId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), imageFrom2015: Object.freeze({ @@ -499,6 +517,8 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), video: Object.freeze({ @@ -539,6 +559,8 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), livePhotoMotionAsset: Object.freeze({ @@ -556,6 +578,8 @@ export const assetStub = { files: [] as AssetFile[], libraryId: null, visibility: AssetVisibility.Hidden, + width: null, + height: null, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), livePhotoStillAsset: Object.freeze({ @@ -574,6 +598,8 @@ export const assetStub = { files, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, + width: null, + height: null, } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), livePhotoWithOriginalFileName: Object.freeze({ @@ -594,6 +620,8 @@ export const assetStub = { libraryId: null, faces: [] as AssetFace[], visibility: AssetVisibility.Timeline, + width: null, + height: null, } as MapAsset & { faces: AssetFace[]; files: AssetFile[] }), withLocation: Object.freeze({ @@ -638,6 +666,8 @@ export const assetStub = { isOffline: false, tags: [], visibility: AssetVisibility.Timeline, + width: null, + height: null, }), sidecar: Object.freeze({ @@ -673,6 +703,8 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), sidecarWithoutExt: Object.freeze({ @@ -705,6 +737,8 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), hasEncodedVideo: Object.freeze({ @@ -744,6 +778,8 @@ export const assetStub = { stackId: null, stack: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), hasFileExtension: Object.freeze({ @@ -780,6 +816,8 @@ export const assetStub = { duplicateId: null, isOffline: false, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), imageDng: Object.freeze({ @@ -820,6 +858,8 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), imageHif: Object.freeze({ @@ -860,6 +900,8 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), panoramaTif: Object.freeze({ id: 'asset-id', @@ -899,5 +941,7 @@ export const assetStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: null, + height: null, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 19a62ad193..2d692a270f 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -72,6 +72,8 @@ const assetResponse: AssetResponseDto = { libraryId: 'library-id', hasMetadata: true, visibility: AssetVisibility.Timeline, + width: null, + height: null, }; const assetResponseWithoutMetadata = { @@ -83,6 +85,8 @@ const assetResponseWithoutMetadata = { duration: '0:00:00.00000', livePhotoVideoId: null, hasMetadata: false, + width: 500, + height: 500, } as AssetResponseDto; const albumResponse: AlbumResponseDto = { @@ -257,6 +261,8 @@ export const sharedLinkStub = { libraryId: null, stackId: null, visibility: AssetVisibility.Timeline, + width: 500, + height: 500, }, ], }, diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 4f053937b8..6c094c1121 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => { livePhotoVideoId: null, stackId: null, libraryId: null, + width: 1920, + height: 1080, }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); @@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => { livePhotoVideoId: asset.livePhotoVideoId, stackId: asset.stackId, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, type: SyncEntityType.AlbumAssetCreateV1, }, diff --git a/server/test/medium/specs/sync/sync-asset.spec.ts b/server/test/medium/specs/sync/sync-asset.spec.ts index 066cb2de4d..acba274b4f 100644 --- a/server/test/medium/specs/sync/sync-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-asset.spec.ts @@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => { deletedAt: null, duration: '0:10:00.00000', libraryId: null, + width: 1920, + height: 1080, }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]); @@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => { stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, + width: asset.width, + height: asset.height, }, type: 'AssetV1', }, diff --git a/server/test/medium/specs/sync/sync-partner-asset.spec.ts b/server/test/medium/specs/sync/sync-partner-asset.spec.ts index c30cfcf6bd..421423a741 100644 --- a/server/test/medium/specs/sync/sync-partner-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-partner-asset.spec.ts @@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => { stackId: null, livePhotoVideoId: null, libraryId: asset.libraryId, + width: null, + height: null, }, type: SyncEntityType.PartnerAssetV1, }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 13cccce176..fa36351315 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -249,6 +249,8 @@ const assetFactory = (asset: Partial = {}) => ({ thumbhash: null, type: AssetType.Image, visibility: AssetVisibility.Timeline, + width: null, + height: null, ...asset, }); diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 88316ccafb..a5a59261cd 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -28,6 +28,8 @@ export const assetFactory = Sync.makeFactory({ isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), visibility: AssetVisibility.Timeline, + width: faker.number.int({ min: 100, max: 1000 }), + height: faker.number.int({ min: 100, max: 1000 }), }); export const timelineAssetFactory = Sync.makeFactory({