From 557a79f7470dd0a7391ee4ace93ab89160831767 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:30:55 -0600 Subject: [PATCH 01/11] chore(mobile): log failures from share upload intent (#24680) chore: log failures from share intent upload Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../share_intent_upload.provider.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 Date: Thu, 18 Dec 2025 22:07:58 +0100 Subject: [PATCH 02/11] fix(mobile): infinite loading screen when hiding UI in map viewer on iOS (#24563) * fix with logging * remove logging * analyze --- mobile/lib/presentation/widgets/map/map.widget.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 { From c9b58f589327566c963ed779e84e3118c908e416 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 18 Dec 2025 22:58:22 +0100 Subject: [PATCH 03/11] fix(web): auto-start slideshow when confirming settings modal (#24629) feat(web): auto-start slideshow when confirming settings modal --- web/src/lib/modals/SlideshowSettingsModal.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/lib/modals/SlideshowSettingsModal.svelte b/web/src/lib/modals/SlideshowSettingsModal.svelte index a5852b8593..ee2f44ec6f 100644 --- a/web/src/lib/modals/SlideshowSettingsModal.svelte +++ b/web/src/lib/modals/SlideshowSettingsModal.svelte @@ -14,7 +14,7 @@ } from '@mdi/js'; import { t } from 'svelte-i18n'; import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte'; - import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store'; + import { SlideshowLook, SlideshowNavigation, SlideshowState, slideshowStore } from '../stores/slideshow.store'; const { slideshowDelay, @@ -23,6 +23,7 @@ slideshowLook, slideshowTransition, slideshowAutoplay, + slideshowState, } = slideshowStore; interface Props { @@ -69,6 +70,7 @@ $slideshowLook = tempSlideshowLook; $slideshowTransition = tempSlideshowTransition; $slideshowAutoplay = tempSlideshowAutoplay; + $slideshowState = SlideshowState.PlaySlideshow; onClose(); }; From 125de91c71c8d141ee20587b92deabd6e96bf31a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 18 Dec 2025 20:59:58 -0600 Subject: [PATCH 04/11] fix: merged video in On This Device played with incorrect dimension (#24656) * fix: merged video in On This Device played with incorrect dimension * chore: pr feedback --- mobile/lib/domain/services/asset.service.dart | 66 ++++++++++--------- .../domain/services/asset.service_test.dart | 42 +++++++++++- 2 files changed, 75 insertions(+), 33 deletions(-) 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/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); From f7cbb7417c134da82d074ba297c32d237bb621eb Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:42:44 -0600 Subject: [PATCH 05/11] fix: dateTimeOriginal timezone updates (#24712) --- server/src/services/asset.service.ts | 34 ++++++- .../specs/services/asset.service.spec.ts | 96 ++++++++++++++++++- 2 files changed, 124 insertions(+), 6 deletions(-) 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/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 8b54019fcf..a7c6470599 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -285,7 +285,7 @@ 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, { dateTimeOriginal: '2023-11-19T18:11:00' }); await expect( ctx.database @@ -296,7 +296,38 @@ describe(AssetService.name, () => { ).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] }); 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 expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .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 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({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }), }), ); }); @@ -329,4 +360,65 @@ 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 expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: null }); + await sut.updateAll(auth, { ids: [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: ['dateTimeOriginal'] }); + 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 expect( + ctx.database + .selectFrom('asset_exif') + .select('lockedProperties') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lockedProperties: null }); + await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' }); + 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({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }), + }), + ); + }); }); From 2e1f8625ec65968c1ff4f26f46394a3d01f2627c Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 19 Dec 2025 15:58:17 +0100 Subject: [PATCH 06/11] fix(web): timezone dropdown showing "No results" when seconds are set to 00 (#24662) * Fix timezone dropdown showing "No results" when seconds are set to 00 * remove comments * add test for #23615 --- .../AssetSelectionChangeDateModal.spec.ts | 28 +++++++++++++++++++ web/src/lib/modals/timezone-utils.ts | 9 +++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts index ab7f24db25..65b77ce5cf 100644 --- a/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts +++ b/web/src/lib/modals/AssetSelectionChangeDateModal.spec.ts @@ -71,6 +71,34 @@ describe('DateSelectionModal component', () => { expect(onClose).toHaveBeenCalled(); }); + test('does not fall back to UTC when datetime-local value has no seconds', async () => { + render(AssetSelectionChangeDateModal, { + props: { initialDate, initialTimeZone, assets: [], onClose }, + }); + + await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00' } }); + await fireEvent.blur(getDateInput()); + + expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)'); + + await fireEvent.focus(getTimeZoneInput()); + expect(screen.queryByText('no_results')).not.toBeInTheDocument(); + }); + + test('does not fall back to UTC when datetime-local value has no milliseconds', async () => { + render(AssetSelectionChangeDateModal, { + props: { initialDate, initialTimeZone, assets: [], onClose }, + }); + + await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00:00' } }); + await fireEvent.blur(getDateInput()); + + expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)'); + + await fireEvent.focus(getTimeZoneInput()); + expect(screen.queryByText('no_results')).not.toBeInTheDocument(); + }); + describe('when date is in daylight saving time', () => { const dstDate = DateTime.fromISO('2024-07-01'); diff --git a/web/src/lib/modals/timezone-utils.ts b/web/src/lib/modals/timezone-utils.ts index fc4081d3bb..157c736dfb 100644 --- a/web/src/lib/modals/timezone-utils.ts +++ b/web/src/lib/modals/timezone-utils.ts @@ -75,8 +75,15 @@ function zoneOptionForDate(zone: string, date: string) { // Ignore milliseconds: // - milliseconds are not relevant for TZ calculations // - browsers strip insignificant .000 making string comparison with milliseconds more fragile. + // + // Also, some browsers emit `datetime-local` values without seconds when seconds are 00, + // e.g. `2024-01-01T00:00` instead of `2024-01-01T00:00:00.000`. + // In that case we must compare with minute precision (otherwise every zone looks "invalid"). const dateInTimezone = DateTime.fromISO(date, { zone }); - const exists = date.replace(/\.\d+/, '') === dateInTimezone.toFormat("yyyy-MM-dd'T'HH:mm:ss"); + const withoutMillis = date.replace(/\.\d+/, ''); + const hasSeconds = /T\d{2}:\d{2}:\d{2}$/.test(withoutMillis); + const compareFormat = hasSeconds ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyy-MM-dd'T'HH:mm"; + const exists = withoutMillis === dateInTimezone.toFormat(compareFormat); const valid = dateInTimezone.isValid && exists; return { value: zone, From 138c9232df2e4340e722e9e724cdde9687c6d7fb Mon Sep 17 00:00:00 2001 From: Timon Date: Fri, 19 Dec 2025 15:58:49 +0100 Subject: [PATCH 07/11] chore: update ApiKeySecretModal to use monospace font (#24690) style(web): update ApiKeySecretModal to use monospace font for readonly textarea --- web/src/lib/modals/ApiKeySecretModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/modals/ApiKeySecretModal.svelte b/web/src/lib/modals/ApiKeySecretModal.svelte index 66c58543c2..98a303f814 100644 --- a/web/src/lib/modals/ApiKeySecretModal.svelte +++ b/web/src/lib/modals/ApiKeySecretModal.svelte @@ -15,7 +15,7 @@ {$t('api_key_description')} -