From 20997823d8c234ce514aaaead72bdf85126627fb Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:55:47 +0200 Subject: [PATCH 1/7] chore: mise monorepo support is now stable (#30131) --- mise.lock | 44 ++++++++++++++++++++++---------------------- mise.toml | 3 +-- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/mise.lock b/mise.lock index bab6b6902d..a400a1e5c7 100644 --- a/mise.lock +++ b/mise.lock @@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" +[tools."github:jellyfin/jellyfin-ffmpeg".options] +asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" + +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] +checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" + +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"] +checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" + +[[tools."github:jellyfin/jellyfin-ffmpeg"]] +version = "7.1.3-6" +backend = "github:jellyfin/jellyfin-ffmpeg" + [tools."github:jellyfin/jellyfin-ffmpeg".options] asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" @@ -112,17 +129,12 @@ version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" [tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" +asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"] +checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889" [[tools."github:jellyfin/jellyfin-ffmpeg"]] version = "7.1.3-6" @@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1 url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094" -[[tools."github:jellyfin/jellyfin-ffmpeg"]] -version = "7.1.3-6" -backend = "github:jellyfin/jellyfin-ffmpeg" - -[tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"] -checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889" - [[tools."github:webassembly/binaryen"]] version = "version_124" backend = "github:webassembly/binaryen" diff --git a/mise.toml b/mise.toml index 3e45d24223..0b11993aa9 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ -experimental_monorepo_root = true +monorepo_root = true [monorepo] config_roots = [ @@ -35,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" } macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" } [settings] -experimental = true pin = true lockfile = true From a0c7b811422921285364850cd72347c4ab853105 Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Wed, 22 Jul 2026 16:30:52 +0530 Subject: [PATCH 2/7] fix(server): dissolve stack when its non-primary assets are deleted (#29354) --- server/src/services/asset.service.spec.ts | 28 +++++++++++++++++++++++ server/src/services/asset.service.ts | 25 ++++++++++++-------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 75e7fc5e87..0264484bfc 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -568,6 +568,34 @@ describe(AssetService.name, () => { expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId); }); + it('should delete the stack when a non-primary asset is deleted and only the primary would remain', async () => { + const asset = AssetFactory.from().build(); + const deletionAsset = { + ...getForAssetDeletion(asset), + stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }] }, + }; + mocks.stack.delete.mockResolvedValue(); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset); + + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + + expect(mocks.stack.delete).toHaveBeenCalledWith(deletionAsset.stack.id); + }); + + it('should keep the stack when a non-primary asset is deleted and the primary plus another asset remain', async () => { + const asset = AssetFactory.from().build(); + const deletionAsset = { + ...getForAssetDeletion(asset), + stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }, { id: newUuid() }] }, + }; + mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset); + + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + + expect(mocks.stack.delete).not.toHaveBeenCalled(); + expect(mocks.stack.update).not.toHaveBeenCalled(); + }); + it('should delete a live photo', async () => { const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build(); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c27bbd4b18..1edccf9483 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -316,18 +316,25 @@ export class AssetService extends BaseService { return JobStatus.Failed; } - // replace the parent of the stack children with a new asset - if (asset.stack?.primaryAssetId === id) { - // this only includes timeline visible assets and excludes the primary asset - const stackAssetIds = asset.stack.assets.map((a) => a.id); - if (stackAssetIds.length >= 2) { - const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; + if (asset.stack) { + // asset.stack.assets only includes timeline visible assets and excludes the primary asset + const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id); + + // the primary survives unless it is the asset being deleted + let remainingCount = remainingStackAssetIds.length; + if (asset.stack.primaryAssetId !== id) { + remainingCount++; + } + + if (remainingCount < 2) { + // 0 or 1 asset would remain: dissolve the stack so it does not linger as a single-asset stack + await this.stackRepository.delete(asset.stack.id); + } else if (asset.stack.primaryAssetId === id) { + // the primary is being deleted but others remain: promote a new primary await this.stackRepository.update(asset.stack.id, { id: asset.stack.id, - primaryAssetId: newPrimaryAssetId, + primaryAssetId: remainingStackAssetIds[0], }); - } else { - await this.stackRepository.delete(asset.stack.id); } } From 12f741d9112afabd6c68319de46232efc7a40cbc Mon Sep 17 00:00:00 2001 From: Jan Bader Date: Wed, 22 Jul 2026 13:25:29 +0200 Subject: [PATCH 3/7] chore(cli): remove -h shorthand for --skip-hash (#30119) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- e2e/src/specs/server/cli/upload.e2e-spec.ts | 2 +- packages/cli/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/src/specs/server/cli/upload.e2e-spec.ts b/e2e/src/specs/server/cli/upload.e2e-spec.ts index 5df36746d7..6ed5030e83 100644 --- a/e2e/src/specs/server/cli/upload.e2e-spec.ts +++ b/e2e/src/specs/server/cli/upload.e2e-spec.ts @@ -650,7 +650,7 @@ describe(`immich upload`, () => { ]); expect(stdout).toBe(''); - expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`); + expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`); expect(exitCode).not.toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c82c7d852b..deef7d8c8c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -47,7 +47,7 @@ program .usage('[paths...] [options]') .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .addOption(new Option('-i, --ignore ', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS')) - .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) + .addOption(new Option('--skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false)) .addOption( new Option('-a, --album', 'Automatically create albums based on folder name') From 457da17c5d02f8f8d2cd4c6c50ace402ffc4873b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 22 Jul 2026 14:59:54 +0200 Subject: [PATCH 4/7] fix: always set extension from provided file (#29839) * fix: always set extension from provided file The livephoto case can also happen with other files; for example, we receive a DNG file as JPEG but then still call it .dng Fixes #29805, partially addresses #28602 * chore: test cases for file extension naming --- .../services/background_upload.service.dart | 11 ++- .../services/foreground_upload.service.dart | 15 ++-- .../background_upload.service_test.dart | 45 ++++++++++++ .../foreground_upload.service_test.dart | 72 +++++++++++++++++++ 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index fa0a41f17c..ec731c6f68 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -290,13 +290,10 @@ class BackgroundUploadService { return null; } - String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; - final hasExtension = p.extension(fileName).isNotEmpty; - if (!hasExtension) { - fileName = p.setExtension(fileName, p.extension(asset.name)); - } - - final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; + // Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those. + final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); + final originalFileName = p.setExtension(fileName, extension); String metadata = UploadTaskMetadata( localAssetId: asset.id, diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 06915ad09f..cce1241473 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -309,17 +309,10 @@ class ForegroundUploadService { return; } - String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; - - /// Handle special file name from DJI or Fusion app - /// If the file name has no extension, likely due to special renaming template by specific apps - /// we append the original extension from the asset name - final hasExtension = p.extension(fileName).isNotEmpty; - if (!hasExtension) { - fileName = p.setExtension(fileName, p.extension(asset.name)); - } - - final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; + // Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those. + final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); + final originalFileName = p.setExtension(fileName, extension); final deviceId = Store.get(StoreKey.deviceId); final fields = { diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index 527da1bf1b..65f17501a0 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -136,6 +136,51 @@ void main() { expect(task, isNotNull); expect(task!.fields.containsKey('visibility'), isFalse); }); + + test('corrects the extension when iOS returns a rendered file for a .dng asset', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/IMG_6499.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('IMG_6499.jpg')); + }); + + test('keeps the .dng extension for a genuine RAW original', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/IMG_5210.dng'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('IMG_5210.dng')); + }); + + test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/DJI_0001'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('DJI_0001.jpg')); + }); }); group('getLivePhotoUploadTask', () { diff --git a/mobile/test/services/foreground_upload.service_test.dart b/mobile/test/services/foreground_upload.service_test.dart index ed48270b87..46e9a82141 100644 --- a/mobile/test/services/foreground_upload.service_test.dart +++ b/mobile/test/services/foreground_upload.service_test.dart @@ -81,6 +81,24 @@ void main() { return captured; } + List captureOriginalFileNames() { + final captured = []; + when( + () => mockUploadRepository.uploadFile( + file: any(named: 'file'), + originalFileName: any(named: 'originalFileName'), + fields: any(named: 'fields'), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + logContext: any(named: 'logContext'), + ), + ).thenAnswer((invocation) async { + captured.add(invocation.namedArguments[#originalFileName] as String); + return UploadResult.success(remoteAssetId: 'remote-${captured.length}'); + }); + return captured; + } + group('uploadSingleAsset', () { test('should upload the motion part hidden and keep the still image visible', () async { final asset = LocalAssetStub.image1; @@ -124,5 +142,59 @@ void main() { expect(captured, hasLength(1)); expect(captured[0].containsKey('visibility'), isFalse); }); + + test('corrects the extension when iOS returns a rendered file for a .dng asset', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/IMG_6499.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['IMG_6499.jpg'])); + }); + + test('keeps the .dng extension for a genuine RAW original', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/IMG_5210.dng'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['IMG_5210.dng'])); + }); + + test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/DJI_0001'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['DJI_0001.jpg'])); + }); }); } From 23778551f7960e1953b46010c44eb9b40b583753 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:21:33 +0530 Subject: [PATCH 5/7] fix: locked view and asset view provider (#30136) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/providers/routes.provider.dart | 15 ++++++-- .../lib/routing/app_navigation_observer.dart | 35 ------------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index a752a51a7d..6cc3d08c5d 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -1,8 +1,19 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/routing/router.dart'; + +@visibleForTesting +bool isRouteInStack(Ref ref, String routeName) { + final router = ref.watch(appRouterProvider); + void onChange() => ref.invalidateSelf(); + router.addListener(onChange); + ref.onDispose(() => router.removeListener(onChange)); + return router.stackData.any((route) => route.name == routeName); +} + +final inLockedViewProvider = Provider((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name)); +final isAssetViewerOpenProvider = Provider((ref) => isRouteInStack(ref, AssetViewerRoute.name)); -final inLockedViewProvider = StateProvider((ref) => false); -final isAssetViewerOpenProvider = StateProvider((ref) => false); final currentRouteNameProvider = StateProvider((ref) => null); final previousRouteNameProvider = StateProvider((ref) => null); final previousRouteDataProvider = StateProvider((ref) => null); diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 09d17da182..57304af44f 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; class AppNavigationObserver extends AutoRouterObserver { /// Riverpod Instance @@ -12,46 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver { AppNavigationObserver({required this.ref}); - @override - Future didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async { - unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false)); - } - @override void didPush(Route route, Route? previousRoute) { - _handleDriftLockedFolderState(route, previousRoute); Future(() { ref.read(currentRouteNameProvider.notifier).state = route.settings.name; ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name; ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings; - if (route.settings.name == AssetViewerRoute.name) { - ref.read(isAssetViewerOpenProvider.notifier).state = true; - } }); } - - @override - void didPop(Route route, Route? previousRoute) { - _handleDriftLockedFolderState(previousRoute ?? route, null); - if (route.settings.name == AssetViewerRoute.name) { - Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false); - } - } - - _handleDriftLockedFolderState(Route route, Route? previousRoute) { - final isInLockedView = ref.read(inLockedViewProvider); - final isFromLockedViewToDetailView = - route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name; - - final isFromDetailViewToInfoPanelView = - route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView; - - if (route.settings.name == DriftLockedFolderRoute.name || - isFromLockedViewToDetailView || - isFromDetailViewToInfoPanelView) { - Future(() => ref.read(inLockedViewProvider.notifier).state = true); - } else { - Future(() => ref.read(inLockedViewProvider.notifier).state = false); - } - } } From b9f6c4aaf22fd032cf1cc4357db06fe9ca4e3abc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 Jul 2026 12:09:05 -0400 Subject: [PATCH 6/7] feat: password invalidate sessions (#30125) --- .../img/reset-admin-password.webp | Bin 9066 -> 0 bytes docs/docs/administration/server-commands.md | 1 + .../immich-admin/immich-admin.e2e-spec.ts | 4 ++++ server/src/commands/index.ts | 4 ++-- .../commands/reset-admin-password.command.ts | 20 +++++++++++++----- server/src/services/cli.service.spec.ts | 19 +++++++++++++++-- server/src/services/cli.service.ts | 10 +++++++-- 7 files changed, 47 insertions(+), 11 deletions(-) delete mode 100644 docs/docs/administration/img/reset-admin-password.webp diff --git a/docs/docs/administration/img/reset-admin-password.webp b/docs/docs/administration/img/reset-admin-password.webp deleted file mode 100644 index 5062d24a6b79f8515e8b78a8fd743965a171c34d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9066 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

YEkRBU8M`o~BezqD4f6Q~J^zvjGDAVO=oXDr!V) z27_txasRk759K3>Sft;0OiXC0^>s5sew*sT=+pW;XkWE20b}nz-{p2TprLXd8S9$| zTDYhJXbAyNI}zOJ_SUIb{V;WKH@9Er)wz$AQtXx+nT*(&>4(%DVd(@)!J#mr22+(XAJ;S@K4L95G8q)Yq_q!oig>64N&UbYKin;c%`}*%;asf zTL?~~3AJs|Wrf4Y)HtMb6?hs|N{!``48Q0|Uf~P%#^oc}>KuODSKm7+nz%4u7u7=U zmfRIl1QJb7x9_nljgF4|4V7EXn(dPEVyAVc`54lCJfUk6zA{f~*OaB7`lQupJM{%Sr{7sp+%E3u0Nr%_vfujT5d| z8R=7`!T3EN{_1JSRl@}I+2Ust1Q{{;N!Us}V&m2Tqj=4e4kXe_X>hFYAyD@}GF~`% z{19p3ew(?jVKFEBX2@{;WrwM(J@SHzB@zRvovAqL?;`j$xJug)4=?8J|IgLVis4wh crc{x#J5U5KxuQ_*Qb|g9NTmP(000000D7XF4FCWD diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 104c16c0a2..49bcf17700 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -35,6 +35,7 @@ Found Admin: - Email=admin@example.com - Name=Immich Admin ? Please choose a new password (optional) immich-is-cool +? Invalidate existing sessions? Yes The admin password has been updated. ``` diff --git a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts index 24699cda30..ad0e43ff14 100644 --- a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts +++ b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts @@ -65,6 +65,10 @@ describe(`immich-admin`, () => { child.stdout.on('data', (chunk) => { data += chunk; if (data.includes('Please choose a new password (optional)')) { + child.stdin.write('\n'); + } + + if (data.includes('Invalidate existing sessions?')) { child.stdin.end('\n'); } }); diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 2a2dd1857d..0d03e4cd95 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -8,13 +8,13 @@ import { } from 'src/commands/media-location.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; -import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { SchemaCheck } from 'src/commands/schema-check'; import { VersionCommand } from 'src/commands/version.command'; export const commandsAndQuestions = [ ResetAdminPasswordCommand, - PromptPasswordQuestions, + PromptPasswordResetQuestions, PromptEmailQuestion, EnablePasswordLoginCommand, DisablePasswordLoginCommand, diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index e5dee49837..703f693326 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { CliService } from 'src/services/cli.service'; const prompt = (inquirer: InquirerService) => { - return function ask(admin: UserAdminResponseDto) { + return (admin: UserAdminResponseDto) => { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} @@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => { - Email=${email} - Name=${name}`); - return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); + return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {}); }; }; @@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner { } } -@QuestionSet({ name: 'prompt-password' }) -export class PromptPasswordQuestions { +@QuestionSet({ name: 'prompt-password-reset' }) +export class PromptPasswordResetQuestions { @Question({ message: 'Please choose a new password (optional)', - name: 'password', + name: 'newPassword', }) parsePassword(value: string) { return value; } + + @Question({ + type: 'confirm', + message: 'Invalidate existing sessions?', + default: true, + name: 'invalidateSessions', + }) + parseInvalidate(value: boolean): boolean { + return value; + } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 347d9eef00..29c62d258f 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -37,7 +37,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); - const ask = vitest.fn().mockImplementation(() => {}); + const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -47,6 +47,7 @@ describe(CliService.name, () => { expect(ask).toHaveBeenCalled(); expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); + expect(mocks.session.invalidateAll).not.toHaveBeenCalled(); }); it('should use the supplied password', async () => { @@ -55,7 +56,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); - const ask = vitest.fn().mockResolvedValue('new-password'); + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -66,6 +67,20 @@ describe(CliService.name, () => { expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); + + it('should invalidate existing sessions when requested', async () => { + const admin = UserFactory.create({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(admin); + mocks.session.invalidateAll.mockResolvedValue(void 0); + + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true }); + + await sut.resetAdminPassword(ask); + + expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id }); + }); }); describe('disablePasswordLogin', () => { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 23d70b6860..f37353d7a1 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -58,18 +58,24 @@ export class CliService extends BaseService { return users.map((user) => mapUserAdmin(user)); } - async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { + async resetAdminPassword( + ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>, + ) { const admin = await this.userRepository.getAdmin(); if (!admin) { throw new Error('Admin account does not exist'); } - const providedPassword = await ask(mapUserAdmin(admin)); + const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin)); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); + if (invalidateSessions) { + await this.sessionRepository.invalidateAll({ userId: admin.id }); + } + return { admin, password, provided: !!providedPassword }; } From a7f1d495c6cd3a5f51f922ad6a808e2cc774a8c1 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Jul 2026 11:44:14 -0500 Subject: [PATCH 7/7] fix: wrong corner radius of recently added link (#30140) --- mobile/lib/presentation/pages/search/drift_search.page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 2ae6371f6a..22546b8e50 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget { _QuickLink( title: context.t.recently_added, icon: Icons.upload_outlined, - isTop: true, onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()), ), _QuickLink(