diff --git a/cli/package.json b/cli/package.json index 38b46a9a05..8ae1bb01e1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.104", + "version": "2.2.105", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 34d8e6e59f..a3fd0be914 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,8 @@ [ + { + "label": "v2.4.1", + "url": "https://docs.v2.4.1.archive.immich.app" + }, { "label": "v2.4.0", "url": "https://docs.v2.4.0.archive.immich.app" diff --git a/e2e/package.json b/e2e/package.json index b7ccd8e1e1..bc7b6521e9 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "2.4.0", + "version": "2.4.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 25b936bc36..82614e7a9e 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "immich-ml" -version = "2.4.0" +version = "2.4.1" description = "" authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] requires-python = ">=3.10,<4.0" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 33fe8f978b..b20b29c4ee 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3029, - "android.injected.version.name" => "2.4.0", + "android.injected.version.code" => 3030, + "android.injected.version.name" => "2.4.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 3d8fddc9b7..eb78ea0c8e 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -6,6 +6,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; +typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped}); + class AssetService { final RemoteAssetRepository _remoteAssetRepository; final DriftLocalAssetRepository _localAssetRepository; @@ -58,44 +60,48 @@ class AssetService { } Future getAspectRatio(BaseAsset asset) async { - bool isFlipped; - double? width; - double? height; + final dimension = asset is LocalAsset + ? await _getLocalAssetDimensions(asset) + : await _getRemoteAssetDimensions(asset as RemoteAsset); - 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; + if (dimension.width == null || dimension.height == null || dimension.height == 0) { + return 1.0; } + return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!; + } + + Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async { + double? width = asset.width?.toDouble(); + double? height = asset.height?.toDouble(); + int orientation = asset.orientation; + if (width == null || height == null) { - if (asset.hasRemote) { - final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id; - final remoteAsset = await _remoteAssetRepository.get(id); - width = remoteAsset?.width?.toDouble(); - height = remoteAsset?.height?.toDouble(); - } else { - final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; - final localAsset = await _localAssetRepository.get(id); - width = localAsset?.width?.toDouble(); - height = localAsset?.height?.toDouble(); - } + final fetched = await _localAssetRepository.get(asset.id); + width = fetched?.width?.toDouble(); + height = fetched?.height?.toDouble(); + orientation = fetched?.orientation ?? 0; } - final orientedWidth = isFlipped ? height : width; - final orientedHeight = isFlipped ? width : height; - if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) { - return orientedWidth / orientedHeight; + // On Android, local assets need orientation correction for 90°/270° rotations + // On iOS, the Photos framework pre-corrects dimensions + final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270); + return (width: width, height: height, isFlipped: isFlipped); + } + + Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async { + double? width = asset.width?.toDouble(); + double? height = asset.height?.toDouble(); + + if (width == null || height == null) { + final fetched = await _remoteAssetRepository.get(asset.id); + width = fetched?.width?.toDouble(); + height = fetched?.height?.toDouble(); } - return 1.0; + final exif = await getExif(asset); + final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation); + return (width: width, height: height, isFlipped: isFlipped); } Future> getPlaces(String userId) { diff --git a/mobile/lib/presentation/widgets/map/map.widget.dart b/mobile/lib/presentation/widgets/map/map.widget.dart index 4e4ae45098..17dcffdade 100644 --- a/mobile/lib/presentation/widgets/map/map.widget.dart +++ b/mobile/lib/presentation/widgets/map/map.widget.dart @@ -12,6 +12,8 @@ import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map_utils.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -114,6 +116,14 @@ class _DriftMapState extends ConsumerState { return; } + // When the AssetViewer is open, the DriftMap route stays alive in the background. + // If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed, + // which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever"). + final currentRoute = ref.read(currentRouteNameProvider); + if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) { + return; + } + final bounds = await controller.getVisibleRegion(); unawaited( _reloadMutex.run(() async { diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index 0f9c32b410..881fdc359f 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/share_intent_service.dart'; import 'package:immich_mobile/services/upload.service.dart'; +import 'package:logging/logging.dart'; import 'package:path/path.dart'; final shareIntentUploadProvider = StateNotifierProvider>( @@ -25,6 +26,7 @@ class ShareIntentUploadStateNotifier extends StateNotifier=3.8.0 <4.0.0' diff --git a/mobile/test/domain/services/asset.service_test.dart b/mobile/test/domain/services/asset.service_test.dart index 5e7179ffa6..ca9defc332 100644 --- a/mobile/test/domain/services/asset.service_test.dart +++ b/mobile/test/domain/services/asset.service_test.dart @@ -87,6 +87,25 @@ void main() { verify(() => mockLocalAssetRepository.get('local-1')).called(1); }); + test('uses fetched asset orientation when dimensions are missing on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + // Original asset has default orientation 0, but dimensions are missing + final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0); + + // Fetched asset has 90° orientation and proper dimensions + final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90); + + when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset); + + final result = await sut.getAspectRatio(localAsset); + + // Should flip dimensions since fetched asset has 90° orientation + expect(result, 1080 / 1920); + verify(() => mockLocalAssetRepository.get('local-1')).called(1); + }); + test('returns 1.0 when dimensions are still unavailable after fetching', () async { final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null); @@ -112,7 +131,9 @@ void main() { expect(result, 1.0); }); - test('handles local asset with remoteId and uses exif from remote', () async { + test('handles local asset with remoteId using local orientation not remote exif', () async { + // When a LocalAsset has a remoteId (merged), we should use local orientation + // because the width/height come from the local asset (pre-corrected on iOS) final localAsset = TestUtils.createLocalAsset( id: 'local-1', remoteId: 'remote-1', @@ -121,9 +142,24 @@ void main() { orientation: 0, ); - final exif = const ExifInfo(orientation: '6'); + final result = await sut.getAspectRatio(localAsset); - when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif); + expect(result, 1920 / 1080); + // Should not call remote exif for LocalAsset + verifyNever(() => mockRemoteAssetRepository.getExif(any())); + }); + + test('handles local asset with remoteId and 90 degree rotation on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + final localAsset = TestUtils.createLocalAsset( + id: 'local-1', + remoteId: 'remote-1', + width: 1920, + height: 1080, + orientation: 90, + ); final result = await sut.getAspectRatio(localAsset); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c052e41a49..fba6d2af80 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14268,7 +14268,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "2.4.0", + "version": "2.4.1", "contact": {} }, "tags": [ diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 7fd8b5fe58..754e11667f 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "2.4.0", + "version": "2.4.1", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 537427ff03..2289e5f73e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 2.4.0 + * 2.4.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package.json b/server/package.json index 278ecede44..4e9e1fdf42 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "2.4.0", + "version": "2.4.1", "description": "", "author": "", "private": true, diff --git a/server/src/emails/components/footer.template.tsx b/server/src/emails/components/footer.template.tsx index c84246bf87..324d7dc003 100644 --- a/server/src/emails/components/footer.template.tsx +++ b/server/src/emails/components/footer.template.tsx @@ -7,14 +7,22 @@ export const ImmichFooter = () => (
- + Get it on Google Play
- Immich + Download on the App Store
diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 282d74a9b1..c584cf134f 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -144,14 +144,28 @@ export class AssetService extends BaseService { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids }); const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined); - const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined); + const exifDto = _.omitBy( + { + latitude, + longitude, + rating, + description, + dateTimeOriginal, + }, + _.isUndefined, + ); + const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined; if (Object.keys(exifDto).length > 0) { await this.assetRepository.updateAllExif(ids, exifDto); } - if ((dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined) { - await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone); + if ( + (dateTimeRelative !== undefined && dateTimeRelative !== 0) || + timeZone !== undefined || + extractedTimeZone?.type === 'fixed' + ) { + await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name); } if (Object.keys(assetDto).length > 0) { @@ -436,7 +450,19 @@ export class AssetService extends BaseService { rating?: number; }) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; - const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); + const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined; + const writes = _.omitBy( + { + description, + dateTimeOriginal, + timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined, + latitude, + longitude, + rating, + }, + _.isUndefined, + ); + if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif( updateLockedColumns({ diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts new file mode 100644 index 0000000000..a7af66f872 --- /dev/null +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -0,0 +1,90 @@ +import { Kysely } from 'kysely'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { DB } from 'src/schema'; +import { BaseService } from 'src/services/base.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { ctx } = newMediumService(BaseService, { + database: db || defaultDatabase, + real: [], + mock: [LoggingRepository], + }); + return { ctx, sut: ctx.get(AssetRepository) }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AssetRepository.name, () => { + describe('upsertExif', () => { + it('should append to locked columns', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ + assetId: asset.id, + dateTimeOriginal: '2023-11-19T18:11:00', + lockedProperties: ['dateTimeOriginal'], + }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] }); + + await sut.upsertExif( + { assetId: asset.id, lockedProperties: ['description'] }, + { lockedPropertiesBehavior: 'append' }, + ); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] }); + }); + + it('should deduplicate locked columns', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ + assetId: asset.id, + dateTimeOriginal: '2023-11-19T18:11:00', + lockedProperties: ['dateTimeOriginal', 'description'], + }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] }); + + await sut.upsertExif( + { assetId: asset.id, lockedProperties: ['description'] }, + { lockedPropertiesBehavior: 'append' }, + ); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] }); + }); + }); +}); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 8b54019fcf..661c4f5cdb 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -270,13 +270,13 @@ describe(AssetService.name, () => { }); describe('update', () => { - it('should update dateTimeOriginal', async () => { + it('should automatically lock lockable columns', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queue.mockResolvedValue(); const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const { asset } = await ctx.newAsset({ ownerId: user.id }); - await ctx.newExif({ assetId: asset.id, description: 'test' }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' }); await expect( ctx.database @@ -285,7 +285,14 @@ describe(AssetService.name, () => { .where('assetId', '=', asset.id) .executeTakeFirstOrThrow(), ).resolves.toEqual({ lockedProperties: null }); - await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); + + await sut.update(auth, asset.id, { + latitude: 42, + longitude: 42, + rating: 3, + description: 'foo', + dateTimeOriginal: '2023-11-19T18:11:00+01:00', + }); await expect( ctx.database @@ -293,16 +300,83 @@ describe(AssetService.name, () => { .select('lockedProperties') .where('assetId', '=', asset.id) .executeTakeFirstOrThrow(), - ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] }); + ).resolves.toEqual({ + lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'], + }); + }); + + it('should update dateTimeOriginal', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test' }); + + await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00' }); + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( expect.objectContaining({ - exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }), + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }), + }), + ); + }); + + it('should update dateTimeOriginal with time zone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queue.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test' }); + + await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }), }), ); }); }); describe('updateAll', () => { + it('should automatically lock lockable columns', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: null }); + + await sut.updateAll(auth, { + ids: [asset.id], + latitude: 42, + description: 'foo', + longitude: 42, + rating: 3, + dateTimeOriginal: '2023-11-19T18:11:00+01:00', + }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ + lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'], + }); + }); + it('should relatively update assets', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queueAll.mockResolvedValue(); @@ -313,13 +387,6 @@ describe(AssetService.name, () => { await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 }); - await expect( - ctx.database - .selectFrom('asset_exif') - .select('lockedProperties') - .where('assetId', '=', asset.id) - .executeTakeFirstOrThrow(), - ).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] }); await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( expect.objectContaining({ exifInfo: expect.objectContaining({ @@ -328,5 +395,39 @@ describe(AssetService.name, () => { }), ); }); + + it('should update dateTimeOriginal', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }), + }), + ); + }); + + it('should update dateTimeOriginal with time zone', async () => { + const { sut, ctx } = setup(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: 'test' }); + + await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); + + await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual( + expect.objectContaining({ + exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }), + }), + ); + }); }); }); diff --git a/web/package.json b/web/package.json index 4b3e4587fc..9addfb620a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "2.4.0", + "version": "2.4.1", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 2218e8bf45..2f52423043 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -308,18 +308,6 @@ /> -
- -
- {#if searchStore.isSearchEnabled}
0} > -
+
{/if} +
+ +
+ {#if showClearIcon}
{$t('api_key_description')} -