From 271f1cb868caf6d23b2bd1026b4cfc94a8205567 Mon Sep 17 00:00:00 2001 From: Timon Date: Mon, 11 May 2026 11:49:12 +0200 Subject: [PATCH 01/50] feat(web): Add metadata overlay to slideshow (#24627) * feat: add slideshow metadata overlay and settings * Introduced a new SlideshowMetadataOverlay component to display image information during slideshows. * Updated slideshow settings modal to include options for showing the metadata overlay and selecting its display mode (Description Only or Full). * Added corresponding translations and store management for the new overlay features. * remove noisy log * constant opacity * 2nd pass * more * use text components * lint --- i18n/en.json | 4 ++ .../asset-viewer/AssetViewer.svelte | 5 ++ .../SlideshowMetadataOverlay.svelte | 65 +++++++++++++++++++ .../settings/SettingDropdown.svelte | 4 +- .../lib/modals/SlideshowSettingsModal.svelte | 40 +++++++++++- web/src/lib/stores/slideshow.store.ts | 12 ++++ 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/SlideshowMetadataOverlay.svelte diff --git a/i18n/en.json b/i18n/en.json index 5e073559ac..a15666aed4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2192,6 +2192,7 @@ "show_schema": "Show schema", "show_search_options": "Show search options", "show_shared_links": "Show shared links", + "show_slideshow_metadata_overlay": "Show image info overlay", "show_slideshow_transition": "Show slideshow transition", "show_supporter_badge": "Supporter badge", "show_supporter_badge_description": "Show a supporter badge", @@ -2207,6 +2208,9 @@ "skip_to_folders": "Skip to folders", "skip_to_tags": "Skip to tags", "slideshow": "Slideshow", + "slideshow_metadata_overlay_mode": "Overlay content", + "slideshow_metadata_overlay_mode_description_only": "Description only", + "slideshow_metadata_overlay_mode_full": "Full", "slideshow_repeat": "Repeat slideshow", "slideshow_repeat_description": "Loop back to beginning when slideshow ends", "slideshow_settings": "Slideshow settings", diff --git a/web/src/lib/components/asset-viewer/AssetViewer.svelte b/web/src/lib/components/asset-viewer/AssetViewer.svelte index de6e96fa72..e2b53c8ef1 100644 --- a/web/src/lib/components/asset-viewer/AssetViewer.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewer.svelte @@ -49,6 +49,7 @@ import OcrButton from './OcrButton.svelte'; import PhotoViewer from './PhotoViewer.svelte'; import SlideshowBar from './SlideshowBar.svelte'; + import SlideshowMetadataOverlay from './SlideshowMetadataOverlay.svelte'; import VideoViewer from './VideoWrapperViewer.svelte'; export type AssetCursor = { @@ -588,6 +589,10 @@ {/if} + + {#if $slideshowState !== SlideshowState.None} + + {/if} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset} diff --git a/web/src/lib/components/asset-viewer/SlideshowMetadataOverlay.svelte b/web/src/lib/components/asset-viewer/SlideshowMetadataOverlay.svelte new file mode 100644 index 0000000000..e1865e2c14 --- /dev/null +++ b/web/src/lib/components/asset-viewer/SlideshowMetadataOverlay.svelte @@ -0,0 +1,65 @@ + + +{#if shouldShow} +
+
+
+ {#if description} + {description} + {/if} + {#if $slideshowMetadataOverlayMode !== SlideshowMetadataOverlayMode.DescriptionOnly} +
+ {#if dateString} + {dateString} + {/if} + {#if locationString} + {locationString} + {/if} +
+ {/if} +
+
+
+{/if} diff --git a/web/src/lib/components/shared-components/settings/SettingDropdown.svelte b/web/src/lib/components/shared-components/settings/SettingDropdown.svelte index e65cf8e479..872bf688ce 100644 --- a/web/src/lib/components/shared-components/settings/SettingDropdown.svelte +++ b/web/src/lib/components/shared-components/settings/SettingDropdown.svelte @@ -11,6 +11,7 @@ options: RenderedOption[]; selectedOption: RenderedOption; isEdited?: boolean; + disabled?: boolean; onToggle: (option: RenderedOption) => void; children?: Snippet; } @@ -21,12 +22,13 @@ options, selectedOption = $bindable(), isEdited = false, + disabled = false, onToggle, children, }: Props = $props(); -
+
{/if} From 8f4b0fce49051122636d6a79e7b4bddad49da6b2 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 12 May 2026 11:08:17 +0700 Subject: [PATCH 22/50] fix: limit android background worker duration (#23566) * fix: limit each android background run to 20 mins # Conflicts: # mobile/lib/domain/services/background_worker.service.dart * review chages --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../immich/background/BackgroundWorker.g.kt | 4 +- .../immich/background/BackgroundWorker.kt | 2 +- .../Background/BackgroundWorker.g.swift | 6 +- .../services/background_worker.service.dart | 64 +++++++++++-------- .../lib/platform/background_worker_api.g.dart | 6 +- mobile/pigeon/background_worker_api.dart | 2 +- 6 files changed, 50 insertions(+), 34 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt index 0ae49f87f6..3fcaed34bc 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt @@ -416,12 +416,12 @@ class BackgroundWorkerFlutterApi(private val binaryMessenger: BinaryMessenger, p } } } - fun onAndroidUpload(callback: (Result) -> Unit) + fun onAndroidUpload(maxMinutesArg: Long?, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(null) { + channel.send(listOf(maxMinutesArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt index 7dce1f6edf..716477904c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -107,7 +107,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : * This method acts as a bridge between the native Android background task system and Flutter. */ override fun onInitialized() { - flutterApi?.onAndroidUpload { handleHostResult(it) } + flutterApi?.onAndroidUpload(maxMinutesArg = 20) { handleHostResult(it) } } // TODO: Move this to a separate NotificationManager class diff --git a/mobile/ios/Runner/Background/BackgroundWorker.g.swift b/mobile/ios/Runner/Background/BackgroundWorker.g.swift index 40553441a6..bd01e953f9 100644 --- a/mobile/ios/Runner/Background/BackgroundWorker.g.swift +++ b/mobile/ios/Runner/Background/BackgroundWorker.g.swift @@ -348,7 +348,7 @@ class BackgroundWorkerBgHostApiSetup { /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. protocol BackgroundWorkerFlutterApiProtocol { func onIosUpload(isRefresh isRefreshArg: Bool, maxSeconds maxSecondsArg: Int64?, completion: @escaping (Result) -> Void) - func onAndroidUpload(completion: @escaping (Result) -> Void) + func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result) -> Void) func cancel(completion: @escaping (Result) -> Void) } class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol { @@ -379,10 +379,10 @@ class BackgroundWorkerFlutterApi: BackgroundWorkerFlutterApiProtocol { } } } - func onAndroidUpload(completion: @escaping (Result) -> Void) { + func onAndroidUpload(maxMinutes maxMinutesArg: Int64?, completion: @escaping (Result) -> Void) { let channelName: String = "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFlutterApi.onAndroidUpload\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage(nil) { response in + channel.sendMessage([maxMinutesArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { completion(.failure(createConnectionError(withChannelName: channelName))) return diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index a2e96f2313..0c8746700c 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -105,46 +105,58 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { } @override - Future onAndroidUpload() async { - _logger.info('Android background processing started'); - final sw = Stopwatch()..start(); - try { - if (!await _syncAssets(hashTimeout: Duration(minutes: _isBackupEnabled ? 3 : 6))) { - _logger.warning("Remote sync did not complete successfully, skipping backup"); - return; - } - await _handleBackup(); - } catch (error, stack) { - _logger.severe("Failed to complete Android background processing", error, stack); - } finally { - sw.stop(); - _logger.info("Android background processing completed in ${sw.elapsed.inSeconds}s"); - await _cleanup(); - } + Future onAndroidUpload(int? maxMinutes) async { + final hashTimeout = Duration(minutes: _isBackupEnabled ? 3 : 6); + final backupTimeout = maxMinutes != null ? Duration(minutes: maxMinutes - 1) : null; + return _backgroundLoop( + hashTimeout: hashTimeout, + backupTimeout: backupTimeout, + debugLabel: 'Android background upload', + ); } @override Future onIosUpload(bool isRefresh, int? maxSeconds) async { - _logger.info('iOS background upload started with maxSeconds: ${maxSeconds}s'); + final hashTimeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6); + final backupTimeout = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null; + return _backgroundLoop(hashTimeout: hashTimeout, backupTimeout: backupTimeout, debugLabel: 'iOS background upload'); + } + + Future _backgroundLoop({ + required Duration hashTimeout, + required Duration? backupTimeout, + required String debugLabel, + }) async { + _logger.info( + '$debugLabel started hashTimeout: ${hashTimeout.inSeconds}s, backupTimeout: ${backupTimeout?.inMinutes ?? '~'}m', + ); final sw = Stopwatch()..start(); try { - final timeout = isRefresh ? const Duration(seconds: 5) : Duration(minutes: _isBackupEnabled ? 3 : 6); - if (!await _syncAssets(hashTimeout: timeout)) { + if (!await _syncAssets(hashTimeout: hashTimeout)) { _logger.warning("Remote sync did not complete successfully, skipping backup"); return; } final backupFuture = _handleBackup(); - if (maxSeconds != null) { - await backupFuture.timeout(Duration(seconds: maxSeconds - 1), onTimeout: () {}); - } else { + Timer? cancelTimer; + if (backupTimeout != null) { + cancelTimer = Timer(backupTimeout, () { + if (!_cancellationToken.isCompleted) { + _logger.warning("$debugLabel timed out after ${backupTimeout.inMinutes}m, cancelling backup"); + _cancellationToken.complete(); + } + }); + } + try { await backupFuture; + } finally { + cancelTimer?.cancel(); } } catch (error, stack) { - _logger.severe("Failed to complete iOS background upload", error, stack); + _logger.severe("Failed to complete $debugLabel", error, stack); } finally { sw.stop(); - _logger.info("iOS background upload completed in ${sw.elapsed.inSeconds}s"); + _logger.info("$debugLabel completed in ${sw.elapsed.inSeconds}s"); await _cleanup(); } } @@ -177,7 +189,9 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { final nativeSyncApi = _ref?.read(nativeSyncApiProvider); _logger.info("Cleaning up background worker"); - _cancellationToken.complete(); + if (!_cancellationToken.isCompleted) { + _cancellationToken.complete(); + } final cleanupFutures = [ nativeSyncApi?.cancelHashing(), workerManagerPatch.dispose().catchError((_) async { diff --git a/mobile/lib/platform/background_worker_api.g.dart b/mobile/lib/platform/background_worker_api.g.dart index 580531b0f0..34f4c41b48 100644 --- a/mobile/lib/platform/background_worker_api.g.dart +++ b/mobile/lib/platform/background_worker_api.g.dart @@ -277,7 +277,7 @@ abstract class BackgroundWorkerFlutterApi { Future onIosUpload(bool isRefresh, int? maxSeconds); - Future onAndroidUpload(); + Future onAndroidUpload(int? maxMinutes); Future cancel(); @@ -323,8 +323,10 @@ abstract class BackgroundWorkerFlutterApi { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final int? arg_maxMinutes = args[0] as int?; try { - await api.onAndroidUpload(); + await api.onAndroidUpload(arg_maxMinutes); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); diff --git a/mobile/pigeon/background_worker_api.dart b/mobile/pigeon/background_worker_api.dart index a40d290199..06395fae7b 100644 --- a/mobile/pigeon/background_worker_api.dart +++ b/mobile/pigeon/background_worker_api.dart @@ -47,7 +47,7 @@ abstract class BackgroundWorkerFlutterApi { // Android Only: Called when the Android background upload is triggered @async - void onAndroidUpload(); + void onAndroidUpload(int? maxMinutes); @async void cancel(); From 4eee023648c903b44824a6df65c8eb995f1f2003 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 11:52:30 +0200 Subject: [PATCH 23/50] chore(deps): update docker.io/valkey/valkey:9 docker digest to 8436e10 (#28369) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker/docker-compose.dev.yml | 2 +- docker/docker-compose.prod.yml | 2 +- docker/docker-compose.rootless.yml | 2 +- docker/docker-compose.yml | 2 +- e2e/docker-compose.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 58ed573e1e..5a34bf4d3c 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -157,7 +157,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index d85186b6f1..24ecb02624 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 9e600289b9..3f3e53424b 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 610b375011..5f3ad35245 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 8b69197554..0ccd54cf3f 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -44,7 +44,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:3b55fbaa0cd93cf0d9d961f405e4dfcc70efe325e2d84da207a0a8e6d8fde4f9 + image: docker.io/valkey/valkey:9@sha256:8436e10bc65c94886a91d4415b6a6dfa9cb5a306fb3b996e5bb67cd2b4854193 healthcheck: test: redis-cli ping || exit 1 From ce292bdce9e9f93a6b59d4db404dbbdc71869586 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:02:43 +0200 Subject: [PATCH 24/50] chore(deps): update base-image to v202605051129 (#28374) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- server/Dockerfile.dev | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 049e76bb2e..6e20d900b2 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202604141125@sha256:9338c216fb0fef4172cf53cd8e4ff607c6635d576dcc1366151f13d69bbb45ef AS builder +FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -73,7 +73,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ cd plugins && mise run build -FROM ghcr.io/immich-app/base-server-prod:202604141125@sha256:3b05219afcda09cebfb8513743fc92cec1a3ae262249bfe0de6f90da21326991 +FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index f8a70f03b1..0b2cc0beec 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202604141125@sha256:9338c216fb0fef4172cf53cd8e4ff607c6635d576dcc1366151f13d69bbb45ef AS dev +FROM ghcr.io/immich-app/base-server-dev:202605051129@sha256:d07d8fcdb7e9f3ac22a811e87761ebf341ed0bb91956b89097540c2ed3fb9ca3 AS dev ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ From bcef7aa6b6c5708ffecf9d0806b1bbaa9b7705d3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:07:32 +0200 Subject: [PATCH 25/50] chore(deps): update github-actions (#28372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/check-openapi.yml | 2 +- .github/workflows/close-duplicates.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/pr-labeler.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 47ac1d3730..07c7505762 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@37bf9ff785c7315df88216660826e71be4cc03da # v0.0.44 + uses: oasdiff/oasdiff-action/breaking@26ccb332c67a45ca649de9faf60552ef1b8260d9 # v0.0.46 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/close-duplicates.yml b/.github/workflows/close-duplicates.yml index 55fd5ea0ca..b0b5258048 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -35,7 +35,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:32abe582452b12dff55055e1d6bc24508a8f17164f9d1831db7bb70953c014c6 + image: ghcr.io/immich-app/mdq:main@sha256:0a8b8867773a0f8368061f47578603f438349f8f1f28b0e16105f481e5c794e0 outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 827864cec5..f9e6dbfa2d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -57,7 +57,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -70,7 +70,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -83,6 +83,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 5e547fb47a..4df27e581e 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -17,6 +17,6 @@ jobs: client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 with: repo-token: ${{ steps.token.outputs.token }} From f88fdae048955cc9138be7f6fe74d7ae2759b886 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:15:47 +0200 Subject: [PATCH 26/50] fix(deps): update dependency @immich/ui to ^0.77.0 (#28373) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 41 +++++++---------------------------------- web/package.json | 2 +- 2 files changed, 8 insertions(+), 35 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2dede8603..175217f4d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,8 +731,8 @@ importers: specifier: workspace:* version: link:../packages/sdk '@immich/ui': - specifier: ^0.76.0 - version: 0.76.2(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2) + specifier: ^0.77.0 + version: 0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2) '@mapbox/mapbox-gl-rtl-text': specifier: 0.4.0 version: 0.4.0 @@ -3027,14 +3027,10 @@ packages: resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} hasBin: true - '@immich/svelte-markdown-preprocess@0.4.1': - resolution: {integrity: sha512-/N5dhu3fnRZUoZ+Z9hrIV61o9wi6Uf70TDxqiinXNYlXfqP81p1o77Z5mhbxtNigTNcp6GwpGeHAXRHQrU9JAQ==} - peerDependencies: - svelte: ^5.0.0 - - '@immich/ui@0.76.2': - resolution: {integrity: sha512-D5oqBMyGg8x7YcrmWLgYO1z6d5BU454jejoDJqkW/oJGHMXCSSyY+l/skmVR+fLd1Pttf28gJE9TVG1xXqJ0rA==} + '@immich/ui@0.77.3': + resolution: {integrity: sha512-h3jrYE3JyGDOwXF7A4tVUHenP0L7TsDV22FyFInBTdwlWjjXoknwE1HWeTvvLxLeMuO5SHCZ9Q2D2al84xVjNw==} peerDependencies: + '@sveltejs/kit': ^2.13.0 svelte: ^5.0.0 '@inquirer/ansi@1.0.2': @@ -7778,9 +7774,6 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - front-matter@4.0.2: - resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} - fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -9031,11 +9024,6 @@ packages: engines: {node: '>= 20'} hasBin: true - marked@17.0.6: - resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} - engines: {node: '>= 20'} - hasBin: true - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -15405,18 +15393,11 @@ snapshots: pg-connection-string: 2.12.0 postgres: 3.4.9 - '@immich/svelte-markdown-preprocess@0.4.1(svelte@5.55.2)': + '@immich/ui@0.77.3(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)': dependencies: - front-matter: 4.0.2 - marked: 17.0.6 - node-emoji: 2.2.0 - svelte: 5.55.2 - - '@immich/ui@0.76.2(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)': - dependencies: - '@immich/svelte-markdown-preprocess': 0.4.1(svelte@5.55.2) '@internationalized/date': 3.12.1 '@mdi/js': 7.4.47 + '@sveltejs/kit': 2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) bits-ui: 2.18.0(@internationalized/date@3.12.1)(@sveltejs/kit@2.57.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.2)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2)(typescript@6.0.3)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.2) luxon: 3.7.2 simple-icons: 16.17.0 @@ -15425,8 +15406,6 @@ snapshots: tailwind-merge: 3.5.0 tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.4) tailwindcss: 4.2.4 - transitivePeerDependencies: - - '@sveltejs/kit' '@inquirer/ansi@1.0.2': {} @@ -20636,10 +20615,6 @@ snapshots: fresh@2.0.0: {} - front-matter@4.0.2: - dependencies: - js-yaml: 3.14.2 - fs-constants@1.0.0: {} fs-extra@10.1.0: @@ -22015,8 +21990,6 @@ snapshots: marked@16.4.2: {} - marked@17.0.6: {} - math-intrinsics@1.1.0: {} mdast-util-directive@3.1.0: diff --git a/web/package.json b/web/package.json index fedb4a1c9b..1ff9beb68e 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.76.0", + "@immich/ui": "^0.77.0", "@mapbox/mapbox-gl-rtl-text": "0.4.0", "@mdi/js": "^7.4.47", "@noble/hashes": "^2.2.0", From 4791313def93188dcbd988dc8bd9bbd5fd86a568 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Tue, 12 May 2026 14:12:27 +0200 Subject: [PATCH 27/50] fix: manage oazapfts through mise (#28380) --- mise.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index 21022238cd..768bef5222 100644 --- a/mise.toml +++ b/mise.toml @@ -21,6 +21,7 @@ pnpm = "10.33.1" terragrunt = "1.0.3" opentofu = "1.11.6" java = "21.0.2" +"npm:oazapfts" = "7.5.0" [tools."github:CQLabs/homebrew-dcm"] version = "1.37.0" @@ -42,7 +43,7 @@ pin = true [tasks.open-api-typescript] run = [ - "pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts", + "oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts", { task = "//:sdk:install" }, { task = "//:sdk:build" }, ] From 571e6a8560fbf4031b30e530196e281b4858bfb4 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Tue, 12 May 2026 19:12:26 +0600 Subject: [PATCH 28/50] chore(mobile): drop deprecated deviceAssetId / deviceId from upload fields (#28384) server removed both fields from AssetMediaCreateDto in #27818. zod silently strips unknown fields so uploads still work, but we send dead weight on every request. drop from foreground + background upload paths + share intent path. deviceAssetId stays as the internal background_downloader taskId, just not in the multipart form fields anymore. --- mobile/lib/services/background_upload.service.dart | 3 --- mobile/lib/services/foreground_upload.service.dart | 7 ------- 2 files changed, 10 deletions(-) diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index 92be0bfb7f..ab091f3925 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -394,12 +394,9 @@ class BackgroundUploadService { final serverEndpoint = Store.get(StoreKey.serverEndpoint); final url = Uri.parse('$serverEndpoint/assets').toString(); final headers = ApiService.getRequestHeaders(); - final deviceId = Store.get(StoreKey.deviceId); final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); final fieldsMap = { 'filename': originalFileName ?? filename, - 'deviceAssetId': deviceAssetId ?? '', - 'deviceId': deviceId, 'fileCreatedAt': createdAt.toUtc().toIso8601String(), 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'isFavorite': isFavorite?.toString() ?? 'false', diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 258a3105f5..2a3ebcb62a 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -5,8 +5,6 @@ import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -321,11 +319,8 @@ class ForegroundUploadService { } final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; - final deviceId = Store.get(StoreKey.deviceId); final fields = { - 'deviceAssetId': asset.localId!, - 'deviceId': deviceId, 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'isFavorite': asset.isFavorite.toString(), @@ -431,8 +426,6 @@ class ForegroundUploadService { final filename = p.basename(file.path); final fields = { - 'deviceAssetId': deviceAssetId, - 'deviceId': Store.get(StoreKey.deviceId), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'isFavorite': 'false', From ad7aedb8433e72b7e76b2c417671dfb6f91cbe20 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 12 May 2026 09:28:30 -0400 Subject: [PATCH 29/50] refactor: move plugins to packages (#28389) --- .../server/container-compose-overrides.yml | 2 +- docker/docker-compose.dev.yml | 2 +- mise.toml | 2 +- {plugins => packages/plugins}/.gitignore | 0 {plugins => packages/plugins}/LICENSE | 0 {plugins => packages/plugins}/esbuild.js | 0 {plugins => packages/plugins}/manifest.json | 0 {plugins => packages/plugins}/mise.toml | 0 .../plugins}/package-lock.json | 0 {plugins => packages/plugins}/package.json | 0 {plugins => packages/plugins}/src/index.d.ts | 0 {plugins => packages/plugins}/src/index.ts | 0 {plugins => packages/plugins}/tsconfig.json | 0 pnpm-lock.yaml | 24 +++++++++---------- server/Dockerfile | 14 +++++------ 15 files changed, 22 insertions(+), 22 deletions(-) rename {plugins => packages/plugins}/.gitignore (100%) rename {plugins => packages/plugins}/LICENSE (100%) rename {plugins => packages/plugins}/esbuild.js (100%) rename {plugins => packages/plugins}/manifest.json (100%) rename {plugins => packages/plugins}/mise.toml (100%) rename {plugins => packages/plugins}/package-lock.json (100%) rename {plugins => packages/plugins}/package.json (100%) rename {plugins => packages/plugins}/src/index.d.ts (100%) rename {plugins => packages/plugins}/src/index.ts (100%) rename {plugins => packages/plugins}/tsconfig.json (100%) diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 5c312efd07..8f9e562e0a 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -16,7 +16,7 @@ services: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../plugins:/build/corePlugin + - ../packages/plugins:/build/corePlugin immich-web: env_file: !reset [] immich-machine-learning: diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5a34bf4d3c..dfb876e6bd 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -74,7 +74,7 @@ services: - ${UPLOAD_LOCATION}/photos:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../plugins:/build/corePlugin + - ../packages/plugins:/build/corePlugin env_file: - .env environment: diff --git a/mise.toml b/mise.toml index 768bef5222..a88e3bb2b1 100644 --- a/mise.toml +++ b/mise.toml @@ -2,7 +2,7 @@ experimental_monorepo_root = true [monorepo] config_roots = [ - "plugins", + "packages/plugins", "server", "packages/cli", "deployment", diff --git a/plugins/.gitignore b/packages/plugins/.gitignore similarity index 100% rename from plugins/.gitignore rename to packages/plugins/.gitignore diff --git a/plugins/LICENSE b/packages/plugins/LICENSE similarity index 100% rename from plugins/LICENSE rename to packages/plugins/LICENSE diff --git a/plugins/esbuild.js b/packages/plugins/esbuild.js similarity index 100% rename from plugins/esbuild.js rename to packages/plugins/esbuild.js diff --git a/plugins/manifest.json b/packages/plugins/manifest.json similarity index 100% rename from plugins/manifest.json rename to packages/plugins/manifest.json diff --git a/plugins/mise.toml b/packages/plugins/mise.toml similarity index 100% rename from plugins/mise.toml rename to packages/plugins/mise.toml diff --git a/plugins/package-lock.json b/packages/plugins/package-lock.json similarity index 100% rename from plugins/package-lock.json rename to packages/plugins/package-lock.json diff --git a/plugins/package.json b/packages/plugins/package.json similarity index 100% rename from plugins/package.json rename to packages/plugins/package.json diff --git a/plugins/src/index.d.ts b/packages/plugins/src/index.d.ts similarity index 100% rename from plugins/src/index.d.ts rename to packages/plugins/src/index.d.ts diff --git a/plugins/src/index.ts b/packages/plugins/src/index.ts similarity index 100% rename from plugins/src/index.ts rename to packages/plugins/src/index.ts diff --git a/plugins/tsconfig.json b/packages/plugins/tsconfig.json similarity index 100% rename from plugins/tsconfig.json rename to packages/plugins/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 175217f4d8..157b0746e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,18 @@ importers: specifier: ^4.20.6 version: 4.21.0 + packages/plugins: + devDependencies: + '@extism/js-pdk': + specifier: ^1.0.1 + version: 1.1.1 + esbuild: + specifier: ^0.28.0 + version: 0.28.0 + typescript: + specifier: ^6.0.0 + version: 6.0.3 + packages/sdk: dependencies: '@oazapfts/runtime': @@ -325,18 +337,6 @@ importers: specifier: ^6.0.0 version: 6.0.3 - plugins: - devDependencies: - '@extism/js-pdk': - specifier: ^1.0.1 - version: 1.1.1 - esbuild: - specifier: ^0.28.0 - version: 0.28.0 - typescript: - specifier: ^6.0.0 - version: 6.0.3 - server: dependencies: '@extism/extism': diff --git a/server/Dockerfile b/server/Dockerfile index 6e20d900b2..d35d029958 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -57,13 +57,13 @@ ARG TARGETPLATFORM COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise WORKDIR /usr/src/app -COPY ./plugins/mise.toml ./plugins/ -ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml +COPY ./packages/plugins/mise.toml ./packages/plugins/ +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/packages/plugins/mise.toml ENV MISE_DATA_DIR=/buildcache/mise RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - mise install --cd plugins + mise install --cd packages/plugins -COPY ./plugins ./plugins/ +COPY ./packages/plugins ./packages/plugins/ # Build plugins RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=bind,source=package.json,target=package.json \ @@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - cd plugins && mise run build + cd packages/plugins && mise run build FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd @@ -83,8 +83,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli -COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist -COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json +COPY --from=plugins /usr/src/app/packages/plugins/dist /build/corePlugin/dist +COPY --from=plugins /usr/src/app/packages/plugins/manifest.json /build/corePlugin/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE From dc6f8e746e8ae7ffbca15bca0148eaf9fbe178bd Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 12 May 2026 11:19:54 -0500 Subject: [PATCH 30/50] fix: deep link for assets when asset viewer already open (#27971) --- mobile/lib/main.dart | 29 ++++++++---- mobile/lib/services/deep_link.service.dart | 51 +++++----------------- 2 files changed, 32 insertions(+), 48 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 14d09e4cb7..19455be61c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -179,19 +179,32 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve final isColdStart = currentRouteName == null || currentRouteName == SplashScreenRoute.name; + PageRouteInfo? route; if (deepLink.uri.scheme == "immich") { - final proposedRoute = await deepLinkHandler.handleScheme(deepLink, ref, isColdStart); - - return proposedRoute; + route = await deepLinkHandler.handleScheme(deepLink, ref); + } else if (deepLink.uri.host == "my.immich.app") { + route = await deepLinkHandler.handleMyImmichApp(deepLink, ref); + } else { + return DeepLink.path(deepLink.path); } - if (deepLink.uri.host == "my.immich.app") { - final proposedRoute = await deepLinkHandler.handleMyImmichApp(deepLink, ref, isColdStart); - - return proposedRoute; + if (route == null) { + return isColdStart ? DeepLink.defaultPath : DeepLink.none; } - return DeepLink.path(deepLink.path); + // We need to replace the route if the destination is the current route + if (!isColdStart) { + unawaited( + ref.read(appRouterProvider).pushAndPopUntil(route, predicate: (r) => r.settings.name != route!.routeName), + ); + return DeepLink.none; + } + + return DeepLink([ + // we need something to segue back to if the app was cold started + if (isColdStart) const TabShellRoute(children: [MainTimelineRoute()]), + route, + ]); } @override diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index 3d2d702917..26f2fb685b 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -45,21 +45,12 @@ class DeepLinkService { this._currentUser, ); - DeepLink _handleColdStart(PageRouteInfo route, bool isColdStart) { - return DeepLink([ - // we need something to segue back to if the app was cold started - // TODO: use MainTimelineRoute this when beta is default - if (isColdStart) const TabShellRoute(), - route, - ]); - } - - Future handleScheme(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async { + Future handleScheme(PlatformDeepLink link, WidgetRef ref) async { // get everything after the scheme, since Uri cannot parse path final intent = link.uri.host; final queryParams = link.uri.queryParameters; - PageRouteInfo? deepLinkRoute = switch (intent) { + return switch (intent) { "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), @@ -67,20 +58,9 @@ class DeepLinkService { "activity" => await _buildActivityDeepLink(queryParams['albumId'] ?? ''), _ => null, }; - - // Deep link resolution failed, safely handle it based on the app state - if (deepLinkRoute == null) { - if (isColdStart) { - return DeepLink.defaultPath; - } - - return DeepLink.none; - } - - return _handleColdStart(deepLinkRoute, isColdStart); } - Future handleMyImmichApp(PlatformDeepLink link, WidgetRef ref, bool isColdStart) async { + Future handleMyImmichApp(PlatformDeepLink link, WidgetRef ref) async { final path = link.uri.path; const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; @@ -88,29 +68,20 @@ class DeepLinkService { final albumRegex = RegExp('/albums/($uuidRegex)'); final peopleRegex = RegExp('/people/($uuidRegex)'); - PageRouteInfo? deepLinkRoute; if (assetRegex.hasMatch(path)) { final assetId = assetRegex.firstMatch(path)?.group(1) ?? ''; - deepLinkRoute = await _buildAssetDeepLink(assetId, ref); - } else if (albumRegex.hasMatch(path)) { + return _buildAssetDeepLink(assetId, ref); + } + if (albumRegex.hasMatch(path)) { final albumId = albumRegex.firstMatch(path)?.group(1) ?? ''; - deepLinkRoute = await _buildAlbumDeepLink(albumId); - } else if (peopleRegex.hasMatch(path)) { + return _buildAlbumDeepLink(albumId); + } + if (peopleRegex.hasMatch(path)) { final peopleId = peopleRegex.firstMatch(path)?.group(1) ?? ''; - deepLinkRoute = await _buildPeopleDeepLink(peopleId); - } else if (path == "/memory") { - deepLinkRoute = await _buildMemoryDeepLink(null); + return _buildPeopleDeepLink(peopleId); } - // Deep link resolution failed, safely handle it based on the app state - if (deepLinkRoute == null) { - if (isColdStart) { - return DeepLink.defaultPath; - } - return DeepLink.none; - } - - return _handleColdStart(deepLinkRoute, isColdStart); + return null; } Future _buildMemoryDeepLink(String? memoryId) async { From 1932c60e1cf4e55783764c18e9959a643436b808 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 12 May 2026 23:27:55 +0700 Subject: [PATCH 31/50] fix: kekab icon colors in light mode (#28366) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../open_in_browser_action_button.widget.dart | 3 --- .../asset_viewer/viewer_kebab_menu.widget.dart | 14 ++++++++------ mobile/lib/utils/action_button.utils.dart | 7 +------ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart index 17703d0beb..541a9f8093 100644 --- a/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart @@ -12,7 +12,6 @@ class OpenInBrowserActionButton extends ConsumerWidget { final TimelineOrigin origin; final bool iconOnly; final bool menuItem; - final Color? iconColor; const OpenInBrowserActionButton({ super.key, @@ -20,7 +19,6 @@ class OpenInBrowserActionButton extends ConsumerWidget { required this.origin, this.iconOnly = false, this.menuItem = false, - this.iconColor, }); void _onTap() async { @@ -52,7 +50,6 @@ class OpenInBrowserActionButton extends ConsumerWidget { return BaseActionButton( label: 'open_in_browser'.t(context: context), iconData: Icons.open_in_browser, - iconColor: iconColor, iconOnly: iconOnly, menuItem: menuItem, onPressed: _onTap, diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 78b2e50da5..5a79485daf 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -4,8 +4,8 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; @@ -48,7 +48,6 @@ class ViewerKebabMenu extends ConsumerWidget { source: ActionSource.viewer, isCasting: isCasting, timelineOrigin: timelineOrigin, - originalTheme: originalTheme, ); final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); @@ -67,10 +66,13 @@ class ViewerKebabMenu extends ConsumerWidget { menuChildren: [ ConstrainedBox( constraints: const BoxConstraints(minWidth: 150), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: menuChildren, + child: Theme( + data: originalTheme ?? context.themeData, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: menuChildren, + ), ), ), ], diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 4f7ad83093..3f79cd498f 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -22,10 +22,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browse import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -44,7 +44,6 @@ class ActionButtonContext { final ActionSource source; final bool isCasting; final TimelineOrigin timelineOrigin; - final ThemeData? originalTheme; final int selectedCount; const ActionButtonContext({ @@ -59,7 +58,6 @@ class ActionButtonContext { required this.source, this.isCasting = false, this.timelineOrigin = TimelineOrigin.main, - this.originalTheme, this.selectedCount = 1, }); } @@ -244,7 +242,6 @@ enum ActionButtonType { origin: context.timelineOrigin, iconOnly: iconOnly, menuItem: menuItem, - iconColor: context.originalTheme?.iconTheme.color, ), ActionButtonType.similarPhotos => SimilarPhotosActionButton( assetId: (context.asset as RemoteAsset).id, @@ -259,14 +256,12 @@ enum ActionButtonType { ActionButtonType.openInfo => BaseActionButton( label: 'info'.tr(), iconData: Icons.info_outline, - iconColor: context.originalTheme?.iconTheme.color, menuItem: true, onPressed: () => EventStream.shared.emit(const ViewerShowDetailsEvent()), ), ActionButtonType.viewInTimeline => BaseActionButton( label: 'view_in_timeline'.tr(), iconData: Icons.image_search, - iconColor: context.originalTheme?.iconTheme.color, iconOnly: iconOnly, menuItem: menuItem, onPressed: buildContext == null From 2ddaf6a611ba480cccfb79f06672dc7d00bf95f8 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 12 May 2026 23:43:24 +0700 Subject: [PATCH 32/50] fix: indexes on remote_asset_entity (#28264) * fix: periodically execute pragma optimize * fix: indexes on remote_asset_entity * regen files --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- .../drift_schemas/main/drift_schema_v25.json | 234 +++++++++--------- .../drift_schemas/main/drift_schema_v26.json | 234 +++++++++--------- .../entities/asset_face.entity.dart | 5 + .../entities/asset_face.entity.drift.dart | 4 + .../infrastructure/entities/exif.entity.dart | 4 + .../entities/exif.entity.drift.dart | 5 + .../entities/remote_asset.entity.dart | 13 +- .../entities/remote_asset.entity.drift.dart | 20 +- .../repositories/db.repository.dart | 6 + .../repositories/db.repository.drift.dart | 6 +- .../repositories/db.repository.steps.dart | 56 ++--- .../test/drift/main/generated/schema_v25.dart | 28 +-- .../test/drift/main/generated/schema_v26.dart | 28 +-- 13 files changed, 327 insertions(+), 316 deletions(-) diff --git a/mobile/drift_schemas/main/drift_schema_v25.json b/mobile/drift_schemas/main/drift_schema_v25.json index 95fa57d4d2..5a3f78aae7 100644 --- a/mobile/drift_schemas/main/drift_schema_v25.json +++ b/mobile/drift_schemas/main/drift_schema_v25.json @@ -1003,20 +1003,6 @@ 1 ], "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_owner_checksum", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)", - "unique": false, - "columns": [] - } - }, - { - "id": 12, - "references": [ - 1 - ], - "type": "index", "data": { "on": 1, "name": "UQ_remote_assets_owner_checksum", @@ -1026,7 +1012,7 @@ } }, { - "id": 13, + "id": 12, "references": [ 1 ], @@ -1040,7 +1026,7 @@ } }, { - "id": 14, + "id": 13, "references": [ 1 ], @@ -1054,7 +1040,7 @@ } }, { - "id": 15, + "id": 14, "references": [ 1 ], @@ -1067,36 +1053,22 @@ "columns": [] } }, + { + "id": 15, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_remote_asset_owner_visibility_deleted_created", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created\nON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)\n", + "unique": false, + "columns": [] + } + }, { "id": 16, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_day", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 17, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_month", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 18, "references": [], "type": "table", "data": { @@ -1226,7 +1198,7 @@ } }, { - "id": 19, + "id": 17, "references": [ 0 ], @@ -1301,7 +1273,7 @@ } }, { - "id": 20, + "id": 18, "references": [ 0 ], @@ -1388,7 +1360,7 @@ } }, { - "id": 21, + "id": 19, "references": [ 1 ], @@ -1644,7 +1616,7 @@ } }, { - "id": 22, + "id": 20, "references": [ 1, 4 @@ -1718,7 +1690,7 @@ } }, { - "id": 23, + "id": 21, "references": [ 4, 0 @@ -1806,7 +1778,7 @@ } }, { - "id": 24, + "id": 22, "references": [ 1 ], @@ -1902,7 +1874,7 @@ } }, { - "id": 25, + "id": 23, "references": [ 0 ], @@ -2066,10 +2038,10 @@ } }, { - "id": 26, + "id": 24, "references": [ 1, - 25 + 23 ], "type": "table", "data": { @@ -2140,7 +2112,7 @@ } }, { - "id": 27, + "id": 25, "references": [ 0 ], @@ -2284,10 +2256,10 @@ } }, { - "id": 28, + "id": 26, "references": [ 1, - 27 + 25 ], "type": "table", "data": { @@ -2461,7 +2433,7 @@ } }, { - "id": 29, + "id": 27, "references": [], "type": "table", "data": { @@ -2509,7 +2481,7 @@ } }, { - "id": 30, + "id": 28, "references": [], "type": "table", "data": { @@ -2684,7 +2656,7 @@ } }, { - "id": 31, + "id": 29, "references": [ 1 ], @@ -2778,7 +2750,7 @@ } }, { - "id": 32, + "id": 30, "references": [], "type": "table", "data": { @@ -2826,13 +2798,13 @@ } }, { - "id": 33, + "id": 31, "references": [ - 20 + 18 ], "type": "index", "data": { - "on": 20, + "on": 18, "name": "idx_partner_shared_with_id", "sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)", "unique": false, @@ -2840,19 +2812,47 @@ } }, { - "id": 34, + "id": 32, "references": [ - 21 + 19 ], "type": "index", "data": { - "on": 21, + "on": 19, "name": "idx_lat_lng", "sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)", "unique": false, "columns": [] } }, + { + "id": 33, + "references": [ + 19 + ], + "type": "index", + "data": { + "on": 19, + "name": "idx_remote_exif_city", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city\nON remote_exif_entity (city) WHERE city IS NOT NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 34, + "references": [ + 20 + ], + "type": "index", + "data": { + "on": 20, + "name": "idx_remote_album_asset_album_asset", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", + "unique": false, + "columns": [] + } + }, { "id": 35, "references": [ @@ -2861,20 +2861,6 @@ "type": "index", "data": { "on": 22, - "name": "idx_remote_album_asset_album_asset", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", - "unique": false, - "columns": [] - } - }, - { - "id": 36, - "references": [ - 24 - ], - "type": "index", - "data": { - "on": 24, "name": "idx_remote_asset_cloud_id", "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)", "unique": false, @@ -2882,13 +2868,13 @@ } }, { - "id": 37, + "id": 36, "references": [ - 27 + 25 ], "type": "index", "data": { - "on": 27, + "on": 25, "name": "idx_person_owner_id", "sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)", "unique": false, @@ -2896,13 +2882,13 @@ } }, { - "id": 38, + "id": 37, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_person_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)", "unique": false, @@ -2910,13 +2896,13 @@ } }, { - "id": 39, + "id": 38, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)", "unique": false, @@ -2924,13 +2910,27 @@ } }, { - "id": 40, + "id": 39, "references": [ - 30 + 26 ], "type": "index", "data": { - "on": 30, + "on": 26, + "name": "idx_asset_face_visible_person", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person\nON asset_face_entity (person_id, asset_id)\nWHERE is_visible = 1 AND deleted_at IS NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 40, + "references": [ + 28 + ], + "type": "index", + "data": { + "on": 28, "name": "idx_trashed_local_asset_checksum", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)", "unique": false, @@ -2940,11 +2940,11 @@ { "id": 41, "references": [ - 30 + 28 ], "type": "index", "data": { - "on": 30, + "on": 28, "name": "idx_trashed_local_asset_album", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)", "unique": false, @@ -2954,11 +2954,11 @@ { "id": 42, "references": [ - 31 + 29 ], "type": "index", "data": { - "on": 31, + "on": 29, "name": "idx_asset_edit_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)", "unique": false, @@ -3066,15 +3066,6 @@ } ] }, - { - "name": "idx_remote_asset_owner_checksum", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)" - } - ] - }, { "name": "UQ_remote_assets_owner_checksum", "sql": [ @@ -3112,20 +3103,11 @@ ] }, { - "name": "idx_remote_asset_local_date_time_day", + "name": "idx_remote_asset_owner_visibility_deleted_created", "sql": [ { "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))" - } - ] - }, - { - "name": "idx_remote_asset_local_date_time_month", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))" + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)" } ] }, @@ -3282,6 +3264,15 @@ } ] }, + { + "name": "idx_remote_exif_city", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL" + } + ] + }, { "name": "idx_remote_album_asset_album_asset", "sql": [ @@ -3327,6 +3318,15 @@ } ] }, + { + "name": "idx_asset_face_visible_person", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL" + } + ] + }, { "name": "idx_trashed_local_asset_checksum", "sql": [ diff --git a/mobile/drift_schemas/main/drift_schema_v26.json b/mobile/drift_schemas/main/drift_schema_v26.json index db90edc24b..b958bcca43 100644 --- a/mobile/drift_schemas/main/drift_schema_v26.json +++ b/mobile/drift_schemas/main/drift_schema_v26.json @@ -1013,20 +1013,6 @@ 1 ], "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_owner_checksum", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)", - "unique": false, - "columns": [] - } - }, - { - "id": 12, - "references": [ - 1 - ], - "type": "index", "data": { "on": 1, "name": "UQ_remote_assets_owner_checksum", @@ -1036,7 +1022,7 @@ } }, { - "id": 13, + "id": 12, "references": [ 1 ], @@ -1050,7 +1036,7 @@ } }, { - "id": 14, + "id": 13, "references": [ 1 ], @@ -1064,7 +1050,7 @@ } }, { - "id": 15, + "id": 14, "references": [ 1 ], @@ -1077,36 +1063,22 @@ "columns": [] } }, + { + "id": 15, + "references": [ + 1 + ], + "type": "index", + "data": { + "on": 1, + "name": "idx_remote_asset_owner_visibility_deleted_created", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created\nON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)\n", + "unique": false, + "columns": [] + } + }, { "id": 16, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_day", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 17, - "references": [ - 1 - ], - "type": "index", - "data": { - "on": 1, - "name": "idx_remote_asset_local_date_time_month", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))", - "unique": false, - "columns": [] - } - }, - { - "id": 18, "references": [], "type": "table", "data": { @@ -1236,7 +1208,7 @@ } }, { - "id": 19, + "id": 17, "references": [ 0 ], @@ -1311,7 +1283,7 @@ } }, { - "id": 20, + "id": 18, "references": [ 0 ], @@ -1398,7 +1370,7 @@ } }, { - "id": 21, + "id": 19, "references": [ 1 ], @@ -1654,7 +1626,7 @@ } }, { - "id": 22, + "id": 20, "references": [ 1, 4 @@ -1728,7 +1700,7 @@ } }, { - "id": 23, + "id": 21, "references": [ 4, 0 @@ -1816,7 +1788,7 @@ } }, { - "id": 24, + "id": 22, "references": [ 1 ], @@ -1912,7 +1884,7 @@ } }, { - "id": 25, + "id": 23, "references": [ 0 ], @@ -2076,10 +2048,10 @@ } }, { - "id": 26, + "id": 24, "references": [ 1, - 25 + 23 ], "type": "table", "data": { @@ -2150,7 +2122,7 @@ } }, { - "id": 27, + "id": 25, "references": [ 0 ], @@ -2294,10 +2266,10 @@ } }, { - "id": 28, + "id": 26, "references": [ 1, - 27 + 25 ], "type": "table", "data": { @@ -2471,7 +2443,7 @@ } }, { - "id": 29, + "id": 27, "references": [], "type": "table", "data": { @@ -2519,7 +2491,7 @@ } }, { - "id": 30, + "id": 28, "references": [], "type": "table", "data": { @@ -2694,7 +2666,7 @@ } }, { - "id": 31, + "id": 29, "references": [ 1 ], @@ -2788,7 +2760,7 @@ } }, { - "id": 32, + "id": 30, "references": [], "type": "table", "data": { @@ -2836,13 +2808,13 @@ } }, { - "id": 33, + "id": 31, "references": [ - 20 + 18 ], "type": "index", "data": { - "on": 20, + "on": 18, "name": "idx_partner_shared_with_id", "sql": "CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)", "unique": false, @@ -2850,19 +2822,47 @@ } }, { - "id": 34, + "id": 32, "references": [ - 21 + 19 ], "type": "index", "data": { - "on": 21, + "on": 19, "name": "idx_lat_lng", "sql": "CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)", "unique": false, "columns": [] } }, + { + "id": 33, + "references": [ + 19 + ], + "type": "index", + "data": { + "on": 19, + "name": "idx_remote_exif_city", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city\nON remote_exif_entity (city) WHERE city IS NOT NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 34, + "references": [ + 20 + ], + "type": "index", + "data": { + "on": 20, + "name": "idx_remote_album_asset_album_asset", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", + "unique": false, + "columns": [] + } + }, { "id": 35, "references": [ @@ -2871,20 +2871,6 @@ "type": "index", "data": { "on": 22, - "name": "idx_remote_album_asset_album_asset", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)", - "unique": false, - "columns": [] - } - }, - { - "id": 36, - "references": [ - 24 - ], - "type": "index", - "data": { - "on": 24, "name": "idx_remote_asset_cloud_id", "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)", "unique": false, @@ -2892,13 +2878,13 @@ } }, { - "id": 37, + "id": 36, "references": [ - 27 + 25 ], "type": "index", "data": { - "on": 27, + "on": 25, "name": "idx_person_owner_id", "sql": "CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)", "unique": false, @@ -2906,13 +2892,13 @@ } }, { - "id": 38, + "id": 37, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_person_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)", "unique": false, @@ -2920,13 +2906,13 @@ } }, { - "id": 39, + "id": 38, "references": [ - 28 + 26 ], "type": "index", "data": { - "on": 28, + "on": 26, "name": "idx_asset_face_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)", "unique": false, @@ -2934,13 +2920,27 @@ } }, { - "id": 40, + "id": 39, "references": [ - 30 + 26 ], "type": "index", "data": { - "on": 30, + "on": 26, + "name": "idx_asset_face_visible_person", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person\nON asset_face_entity (person_id, asset_id)\nWHERE is_visible = 1 AND deleted_at IS NULL\n", + "unique": false, + "columns": [] + } + }, + { + "id": 40, + "references": [ + 28 + ], + "type": "index", + "data": { + "on": 28, "name": "idx_trashed_local_asset_checksum", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)", "unique": false, @@ -2950,11 +2950,11 @@ { "id": 41, "references": [ - 30 + 28 ], "type": "index", "data": { - "on": 30, + "on": 28, "name": "idx_trashed_local_asset_album", "sql": "CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)", "unique": false, @@ -2964,11 +2964,11 @@ { "id": 42, "references": [ - 31 + 29 ], "type": "index", "data": { - "on": 31, + "on": 29, "name": "idx_asset_edit_asset_id", "sql": "CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)", "unique": false, @@ -3076,15 +3076,6 @@ } ] }, - { - "name": "idx_remote_asset_owner_checksum", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)" - } - ] - }, { "name": "UQ_remote_assets_owner_checksum", "sql": [ @@ -3122,20 +3113,11 @@ ] }, { - "name": "idx_remote_asset_local_date_time_day", + "name": "idx_remote_asset_owner_visibility_deleted_created", "sql": [ { "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))" - } - ] - }, - { - "name": "idx_remote_asset_local_date_time_month", - "sql": [ - { - "dialect": "sqlite", - "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))" + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)" } ] }, @@ -3292,6 +3274,15 @@ } ] }, + { + "name": "idx_remote_exif_city", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL" + } + ] + }, { "name": "idx_remote_album_asset_album_asset", "sql": [ @@ -3337,6 +3328,15 @@ } ] }, + { + "name": "idx_asset_face_visible_person", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL" + } + ] + }, { "name": "idx_trashed_local_asset_checksum", "sql": [ diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.dart b/mobile/lib/infrastructure/entities/asset_face.entity.dart index 40fe9ab1c1..b94a0cf094 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.dart @@ -5,6 +5,11 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)') +@TableIndex.sql(''' +CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person +ON asset_face_entity (person_id, asset_id) +WHERE is_visible = 1 AND deleted_at IS NULL +''') class AssetFaceEntity extends Table with DriftDefaultsMixin { const AssetFaceEntity(); diff --git a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart index c97dd545a8..d262325742 100644 --- a/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/asset_face.entity.drift.dart @@ -1350,3 +1350,7 @@ i0.Index get idxAssetFaceAssetId => i0.Index( 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); +i0.Index get idxAssetFaceVisiblePerson => i0.Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', +); diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index e009029ea7..120fbd0c68 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -6,6 +6,10 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)') +@TableIndex.sql(''' +CREATE INDEX IF NOT EXISTS idx_remote_exif_city +ON remote_exif_entity (city) WHERE city IS NOT NULL +''') class RemoteExifEntity extends Table with DriftDefaultsMixin { const RemoteExifEntity(); diff --git a/mobile/lib/infrastructure/entities/exif.entity.drift.dart b/mobile/lib/infrastructure/entities/exif.entity.drift.dart index 8695e2004b..cbe31f5bb4 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.drift.dart @@ -1883,3 +1883,8 @@ class RemoteExifEntityCompanion .toString(); } } + +i0.Index get idxRemoteExifCity => i0.Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', +); diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 1050081b46..8644667168 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -5,9 +5,6 @@ import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; -@TableIndex.sql( - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', -) @TableIndex.sql(''' CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) @@ -20,12 +17,10 @@ WHERE (library_id IS NOT NULL); ''') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)') @TableIndex.sql('CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)') -@TableIndex.sql( - "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME('%Y-%m-%d', local_date_time))", -) -@TableIndex.sql( - "CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME('%Y-%m', local_date_time))", -) +@TableIndex.sql(''' +CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created +ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC) +''') class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const RemoteAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index a2333696b0..8141573343 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart @@ -666,9 +666,9 @@ typedef $$RemoteAssetEntityTableProcessedTableManager = i1.RemoteAssetEntityData, i0.PrefetchHooks Function({bool ownerId}) >; -i0.Index get idxRemoteAssetOwnerChecksum => i0.Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', +i0.Index get uQRemoteAssetsOwnerChecksum => i0.Index( + 'UQ_remote_assets_owner_checksum', + 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', ); class $RemoteAssetEntityTable extends i3.RemoteAssetEntity @@ -1763,10 +1763,6 @@ class RemoteAssetEntityCompanion } } -i0.Index get uQRemoteAssetsOwnerChecksum => i0.Index( - 'UQ_remote_assets_owner_checksum', - 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', -); i0.Index get uQRemoteAssetsOwnerLibraryChecksum => i0.Index( 'UQ_remote_assets_owner_library_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)', @@ -1779,11 +1775,7 @@ i0.Index get idxRemoteAssetStackId => i0.Index( 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); -i0.Index get idxRemoteAssetLocalDateTimeDay => i0.Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', -); -i0.Index get idxRemoteAssetLocalDateTimeMonth => i0.Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', +i0.Index get idxRemoteAssetOwnerVisibilityDeletedCreated => i0.Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 2e3df13d38..e81fe58ba9 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -266,6 +266,12 @@ class Drift extends $Drift { }, from24To25: (m, v25) async { await m.createTable(v25.metadata); + await customStatement('DROP INDEX IF EXISTS idx_remote_asset_owner_checksum'); + await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_day'); + await customStatement('DROP INDEX IF EXISTS idx_remote_asset_local_date_time_month'); + await m.createIndex(v25.idxRemoteAssetOwnerVisibilityDeletedCreated); + await m.createIndex(v25.idxRemoteExifCity); + await m.createIndex(v25.idxAssetFaceVisiblePerson); }, from25To26: (m, v26) async { await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt); diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 01f44372fb..c43a83f86a 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -113,13 +113,11 @@ abstract class $Drift extends i0.GeneratedDatabase { i4.idxLocalAssetChecksum, i4.idxLocalAssetCloudId, i3.idxStackPrimaryAssetId, - i2.idxRemoteAssetOwnerChecksum, i2.uQRemoteAssetsOwnerChecksum, i2.uQRemoteAssetsOwnerLibraryChecksum, i2.idxRemoteAssetChecksum, i2.idxRemoteAssetStackId, - i2.idxRemoteAssetLocalDateTimeDay, - i2.idxRemoteAssetLocalDateTimeMonth, + i2.idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -137,11 +135,13 @@ abstract class $Drift extends i0.GeneratedDatabase { metadataEntity, i10.idxPartnerSharedWithId, i11.idxLatLng, + i11.idxRemoteExifCity, i12.idxRemoteAlbumAssetAlbumAsset, i14.idxRemoteAssetCloudId, i17.idxPersonOwnerId, i18.idxAssetFacePersonId, i18.idxAssetFaceAssetId, + i18.idxAssetFaceVisiblePerson, i20.idxTrashedLocalAssetChecksum, i20.idxTrashedLocalAssetAlbum, i21.idxAssetEditAssetId, diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 21d915f8af..1fb88de1d0 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -12390,13 +12390,11 @@ final class Schema25 extends i0.VersionedSchema { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -12414,11 +12412,13 @@ final class Schema25 extends i0.VersionedSchema { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, @@ -12583,10 +12583,6 @@ final class Schema25 extends i0.VersionedSchema { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -12603,13 +12599,9 @@ final class Schema25 extends i0.VersionedSchema { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final Shape40 authUserEntity = Shape40( source: i0.VersionedTable( @@ -12883,6 +12875,10 @@ final class Schema25 extends i0.VersionedSchema { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + final i1.Index idxRemoteExifCity = i1.Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -12903,6 +12899,10 @@ final class Schema25 extends i0.VersionedSchema { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + final i1.Index idxAssetFaceVisiblePerson = i1.Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); final i1.Index idxTrashedLocalAssetChecksum = i1.Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', @@ -12959,13 +12959,11 @@ final class Schema26 extends i0.VersionedSchema { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -12983,11 +12981,13 @@ final class Schema26 extends i0.VersionedSchema { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, @@ -13153,10 +13153,6 @@ final class Schema26 extends i0.VersionedSchema { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - final i1.Index idxRemoteAssetOwnerChecksum = i1.Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -13173,13 +13169,9 @@ final class Schema26 extends i0.VersionedSchema { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + final i1.Index idxRemoteAssetOwnerVisibilityDeletedCreated = i1.Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final Shape40 authUserEntity = Shape40( source: i0.VersionedTable( @@ -13453,6 +13445,10 @@ final class Schema26 extends i0.VersionedSchema { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + final i1.Index idxRemoteExifCity = i1.Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -13473,6 +13469,10 @@ final class Schema26 extends i0.VersionedSchema { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + final i1.Index idxAssetFaceVisiblePerson = i1.Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); final i1.Index idxTrashedLocalAssetChecksum = i1.Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', diff --git a/mobile/test/drift/main/generated/schema_v25.dart b/mobile/test/drift/main/generated/schema_v25.dart index 932b50498c..aad45f0bd3 100644 --- a/mobile/test/drift/main/generated/schema_v25.dart +++ b/mobile/test/drift/main/generated/schema_v25.dart @@ -9030,10 +9030,6 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - late final Index idxRemoteAssetOwnerChecksum = Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); late final Index uQRemoteAssetsOwnerChecksum = Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -9050,13 +9046,9 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - late final Index idxRemoteAssetLocalDateTimeDay = Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - late final Index idxRemoteAssetLocalDateTimeMonth = Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + late final Index idxRemoteAssetOwnerVisibilityDeletedCreated = Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final AuthUserEntity authUserEntity = AuthUserEntity(this); late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); @@ -9085,6 +9077,10 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + late final Index idxRemoteExifCity = Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); late final Index idxRemoteAlbumAssetAlbumAsset = Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -9105,6 +9101,10 @@ class DatabaseAtV25 extends GeneratedDatabase { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + late final Index idxAssetFaceVisiblePerson = Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); late final Index idxTrashedLocalAssetChecksum = Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', @@ -9133,13 +9133,11 @@ class DatabaseAtV25 extends GeneratedDatabase { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -9157,11 +9155,13 @@ class DatabaseAtV25 extends GeneratedDatabase { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, diff --git a/mobile/test/drift/main/generated/schema_v26.dart b/mobile/test/drift/main/generated/schema_v26.dart index f9e3af5d7c..b91afd1b8a 100644 --- a/mobile/test/drift/main/generated/schema_v26.dart +++ b/mobile/test/drift/main/generated/schema_v26.dart @@ -9069,10 +9069,6 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_stack_primary_asset_id', 'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)', ); - late final Index idxRemoteAssetOwnerChecksum = Index( - 'idx_remote_asset_owner_checksum', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)', - ); late final Index uQRemoteAssetsOwnerChecksum = Index( 'UQ_remote_assets_owner_checksum', 'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)', @@ -9089,13 +9085,9 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_remote_asset_stack_id', 'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)', ); - late final Index idxRemoteAssetLocalDateTimeDay = Index( - 'idx_remote_asset_local_date_time_day', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))', - ); - late final Index idxRemoteAssetLocalDateTimeMonth = Index( - 'idx_remote_asset_local_date_time_month', - 'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))', + late final Index idxRemoteAssetOwnerVisibilityDeletedCreated = Index( + 'idx_remote_asset_owner_visibility_deleted_created', + 'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_visibility_deleted_created ON remote_asset_entity (owner_id, visibility, deleted_at, created_at DESC)', ); late final AuthUserEntity authUserEntity = AuthUserEntity(this); late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); @@ -9124,6 +9116,10 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_lat_lng', 'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)', ); + late final Index idxRemoteExifCity = Index( + 'idx_remote_exif_city', + 'CREATE INDEX IF NOT EXISTS idx_remote_exif_city ON remote_exif_entity (city) WHERE city IS NOT NULL', + ); late final Index idxRemoteAlbumAssetAlbumAsset = Index( 'idx_remote_album_asset_album_asset', 'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)', @@ -9144,6 +9140,10 @@ class DatabaseAtV26 extends GeneratedDatabase { 'idx_asset_face_asset_id', 'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)', ); + late final Index idxAssetFaceVisiblePerson = Index( + 'idx_asset_face_visible_person', + 'CREATE INDEX IF NOT EXISTS idx_asset_face_visible_person ON asset_face_entity (person_id, asset_id) WHERE is_visible = 1 AND deleted_at IS NULL', + ); late final Index idxTrashedLocalAssetChecksum = Index( 'idx_trashed_local_asset_checksum', 'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)', @@ -9172,13 +9172,11 @@ class DatabaseAtV26 extends GeneratedDatabase { idxLocalAssetChecksum, idxLocalAssetCloudId, idxStackPrimaryAssetId, - idxRemoteAssetOwnerChecksum, uQRemoteAssetsOwnerChecksum, uQRemoteAssetsOwnerLibraryChecksum, idxRemoteAssetChecksum, idxRemoteAssetStackId, - idxRemoteAssetLocalDateTimeDay, - idxRemoteAssetLocalDateTimeMonth, + idxRemoteAssetOwnerVisibilityDeletedCreated, authUserEntity, userMetadataEntity, partnerEntity, @@ -9196,11 +9194,13 @@ class DatabaseAtV26 extends GeneratedDatabase { metadata, idxPartnerSharedWithId, idxLatLng, + idxRemoteExifCity, idxRemoteAlbumAssetAlbumAsset, idxRemoteAssetCloudId, idxPersonOwnerId, idxAssetFacePersonId, idxAssetFaceAssetId, + idxAssetFaceVisiblePerson, idxTrashedLocalAssetChecksum, idxTrashedLocalAssetAlbum, idxAssetEditAssetId, From f632d320f52c419697fe7e4445054e45bd4fe6fe Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 13 May 2026 00:43:15 +0600 Subject: [PATCH 33/50] fix(mobile): clear linkedRemoteAlbumId in reset() so FK refs dont dangle (#28382) * fix(mobile): clear linkedRemoteAlbumId in reset() so FK refs dont dangle reset() runs with foreign_keys off before wiping remote_* tables, so the ON DELETE SET NULL cascade on linkedRemoteAlbumId doesnt fire. local rows keep pointing at deleted remote ids. affects logout (clearLocalData calls reset()) and the server SyncResetV1 path (30 day idle, etc). after re-login, syncLinkedAlbum either silently warns or fires 400s (those are covered by #28299). null the column manually inside the same transaction. cascade still works for normal SyncAlbumDeleteV1. verified on pixel 9a with this branch built locally: logged out, deleted album from web, logged back in. without fix linkedRemoteAlbumId stayed dangling. with fix all three local rows have linkedRemoteAlbumId = NULL after the logout reset, and recovery is clean once manageLinkedAlbums runs again. * fix(mobile): always re-enable foreign_keys in reset() + simplify the update re-enable foreign_keys inside a try/finally so it always runs even if the transaction throws. without this, a failed reset would leave the connection with foreign_keys = OFF and silently disable cascades for everything after (per copilot review). also drop the where filter on the linkedRemoteAlbumId update, unconditional update-all is simpler and we wipe everything in reset anyway (per ganka review). --- .../repositories/sync_stream.repository.dart | 49 +++++++++------- .../sync_stream_repository_test.dart | 56 +++++++++++++++++++ 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index ee859cc2dd..b7593c3202 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.da import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; @@ -45,25 +46,35 @@ class SyncStreamRepository extends DriftDatabaseRepository { // foreign_keys PRAGMA is no-op within transactions // https://www.sqlite.org/pragma.html#pragma_foreign_keys await _db.customStatement('PRAGMA foreign_keys = OFF'); - await transaction(() async { - await _db.assetFaceEntity.deleteAll(); - await _db.memoryAssetEntity.deleteAll(); - await _db.memoryEntity.deleteAll(); - await _db.partnerEntity.deleteAll(); - await _db.personEntity.deleteAll(); - await _db.remoteAlbumAssetEntity.deleteAll(); - await _db.remoteAlbumEntity.deleteAll(); - await _db.remoteAlbumUserEntity.deleteAll(); - await _db.remoteAssetEntity.deleteAll(); - await _db.remoteExifEntity.deleteAll(); - await _db.stackEntity.deleteAll(); - await _db.authUserEntity.deleteAll(); - await _db.userEntity.deleteAll(); - await _db.userMetadataEntity.deleteAll(); - await _db.remoteAssetCloudIdEntity.deleteAll(); - await _db.assetEditEntity.deleteAll(); - }); - await _db.customStatement('PRAGMA foreign_keys = ON'); + try { + await transaction(() async { + // FK cascade (ON DELETE SET NULL) does not fire while foreign_keys = OFF, + // so null linkedRemoteAlbumId manually to avoid dangling pointers in local_album_entity. + await _db.localAlbumEntity.update().write( + const LocalAlbumEntityCompanion(linkedRemoteAlbumId: Value(null)), + ); + await _db.assetFaceEntity.deleteAll(); + await _db.memoryAssetEntity.deleteAll(); + await _db.memoryEntity.deleteAll(); + await _db.partnerEntity.deleteAll(); + await _db.personEntity.deleteAll(); + await _db.remoteAlbumAssetEntity.deleteAll(); + await _db.remoteAlbumEntity.deleteAll(); + await _db.remoteAlbumUserEntity.deleteAll(); + await _db.remoteAssetEntity.deleteAll(); + await _db.remoteExifEntity.deleteAll(); + await _db.stackEntity.deleteAll(); + await _db.authUserEntity.deleteAll(); + await _db.userEntity.deleteAll(); + await _db.userMetadataEntity.deleteAll(); + await _db.remoteAssetCloudIdEntity.deleteAll(); + await _db.assetEditEntity.deleteAll(); + }); + } finally { + // re-enable FK even if the transaction throws, otherwise the connection + // would be left with foreign_keys = OFF, silently disabling cascades. + await _db.customStatement('PRAGMA foreign_keys = ON'); + } }); } catch (error, stack) { _logger.severe('Error: SyncResetV1', error, stack); diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index 39d6d9156e..4199a5b756 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -1,6 +1,10 @@ import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.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'; @@ -184,4 +188,56 @@ void main() { expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set'); }); }); + + group('SyncStreamRepository - reset()', () { + test('nulls linkedRemoteAlbumId on localAlbumEntity so FK refs do not dangle', () async { + const localAlbumId = 'local-1'; + const remoteAlbumId = 'remote-1'; + + await db.remoteAlbumEntity.insertOne( + RemoteAlbumEntityCompanion.insert(id: remoteAlbumId, name: 'Movies', order: AlbumAssetOrder.desc), + ); + await db.localAlbumEntity.insertOne( + LocalAlbumEntityCompanion.insert( + id: localAlbumId, + name: 'Movies', + backupSelection: BackupSelection.selected, + linkedRemoteAlbumId: const drift.Value(remoteAlbumId), + ), + ); + + // sanity: link is set before reset + final before = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect(before.linkedRemoteAlbumId, equals(remoteAlbumId)); + + await sut.reset(); + + final after = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect( + after.linkedRemoteAlbumId, + isNull, + reason: + 'reset() runs with PRAGMA foreign_keys = OFF so the ON DELETE SET NULL cascade does not fire — the link must be nulled manually', + ); + expect(after.name, equals('Movies'), reason: 'local album row itself must be preserved'); + expect(after.backupSelection, equals(BackupSelection.selected)); + + final remoteRows = await db.remoteAlbumEntity.select().get(); + expect(remoteRows, isEmpty, reason: 'reset() still wipes remoteAlbumEntity'); + }); + + test('preserves localAlbumEntity rows that have no linkedRemoteAlbumId', () async { + const localAlbumId = 'local-unlinked'; + await db.localAlbumEntity.insertOne( + LocalAlbumEntityCompanion.insert(id: localAlbumId, name: 'Camera', backupSelection: BackupSelection.none), + ); + + await sut.reset(); + + final after = await (db.localAlbumEntity.select()..where((t) => t.id.equals(localAlbumId))).getSingle(); + expect(after.linkedRemoteAlbumId, isNull); + expect(after.name, equals('Camera')); + expect(after.backupSelection, equals(BackupSelection.none)); + }); + }); } From 58beac8fe0e788bfea3ac55b8dd40b5badabae9e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 12 May 2026 15:21:04 -0400 Subject: [PATCH 34/50] chore: migrate mobile makefile to mise (#28390) --- .github/workflows/build-mobile.yml | 37 +++++---- .github/workflows/static_analysis.yml | 42 ++++------ .github/workflows/test.yml | 21 +++-- docs/docs/developer/pr-checklist.md | 20 ++--- docs/docs/developer/testing.md | 9 +-- mise.toml | 10 +-- mobile/makefile | 47 +++-------- mobile/mise.toml | 107 +++++++------------------- 8 files changed, 101 insertions(+), 192 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 13427cdc24..f3f254e4be 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -90,6 +90,11 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 + with: + github_token: ${{ steps.token.outputs.token }} + - name: Create the Keystore if: ${{ !github.event.pull_request.head.repo.fork }} env: @@ -114,13 +119,6 @@ jobs: mobile/.dart_tool key: build-mobile-gradle-${{ runner.os }}-main - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 - with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml - cache: true - - name: Setup Android SDK uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 with: @@ -131,11 +129,10 @@ jobs: run: flutter pub get - name: Generate translation file - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart - working-directory: ./mobile + run: mise //mobile:codegen:translation - name: Generate platform APIs - run: make pigeon + run: mise //mobile:codegen:pigeon working-directory: ./mobile - name: Build Android App Bundle @@ -205,6 +202,12 @@ jobs: runs-on: macos-15 steps: + - id: token + uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0 + with: + client-id: ${{ secrets.PUSH_O_MATIC_APP_CLIENT_ID }} + private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} + - name: Select Xcode 26 run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer @@ -214,24 +217,20 @@ jobs: ref: ${{ inputs.ref || github.sha }} persist-credentials: false - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml - cache: true + github_token: ${{ steps.token.outputs.token }} - name: Install Flutter dependencies working-directory: ./mobile run: flutter pub get - name: Generate translation files - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart - working-directory: ./mobile + run: mise //mobile:codegen:translation - name: Generate platform APIs - run: make pigeon - working-directory: ./mobile + run: mise //mobile:codegen:pigeon - name: Setup Ruby uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0 diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 70a7294424..10642fbd11 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -60,38 +60,30 @@ jobs: persist-credentials: false token: ${{ steps.token.outputs.token }} - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml + github_token: ${{ steps.token.outputs.token }} - name: Install dependencies - run: dart pub get + run: flutter pub get - name: Install dependencies for UI package - run: dart pub get + run: flutter pub get working-directory: ./mobile/packages/ui - name: Install dependencies for UI Showcase - run: dart pub get + run: flutter pub get working-directory: ./mobile/packages/ui/showcase - - name: Install DCM - uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1 - with: - github-token: ${{ steps.token.outputs.token }} - version: auto - working-directory: ./mobile - - - name: Generate translation file - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart + - name: Generate translation files + run: mise //mobile:codegen:translation - name: Run Build Runner - run: make build + run: mise //mobile:codegen:dart - name: Generate platform API - run: make pigeon + run: mise //mobile:codegen:pigeon - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 @@ -107,20 +99,16 @@ jobs: env: CHANGED_FILES: ${{ steps.verify-changed-files.outputs.changed_files }} run: | - echo "ERROR: Generated files not up to date! Run 'make build' and 'make pigeon' inside the mobile directory" + echo "ERROR: Generated files not up to date! Run 'mise //mobile:codegen:dart' and 'mise //mobile:codegen:pigeon'" echo "Changed files: ${CHANGED_FILES}" exit 1 - - name: Run dart analyze - run: dart analyze --fatal-infos + - name: Run analyze + run: mise //mobile:analyze - - name: Run dart format - run: make format + - name: Run format + run: mise //mobile:format # TODO: Re-enable after upgrading custom_lint # - name: Run dart custom_lint # run: dart run custom_lint - - # TODO: Use https://github.com/CQLabs/dcm-action - - name: Run DCM - run: dcm analyze lib --fatal-style --fatal-warnings diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18a88bd617..4656ff2646 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -551,17 +551,22 @@ jobs: with: persist-credentials: false token: ${{ steps.token.outputs.token }} - - name: Setup Flutter SDK - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 + + - name: Setup Mise + uses: immich-app/devtools/actions/use-mise@cf6e190bacde3d7bda59372a786b36ac7d01536a # use-mise-action-v2.0.1 with: - channel: 'stable' - flutter-version-file: ./mobile/pubspec.yaml - - name: Generate translation file - run: dart run easy_localization:generate -S ../i18n && dart run bin/generate_keys.dart + github_token: ${{ steps.token.outputs.token }} + + - name: Install dependencies + run: flutter pub get working-directory: ./mobile + + - name: Generate translation files + run: mise //mobile:codegen:translation + - name: Run tests - working-directory: ./mobile - run: flutter test -j 1 + run: mise //mobile:test -j 1 + ml-unit-tests: name: Unit Test ML needs: pre-job diff --git a/docs/docs/developer/pr-checklist.md b/docs/docs/developer/pr-checklist.md index e5dc6cc1e5..c4ed44c77b 100644 --- a/docs/docs/developer/pr-checklist.md +++ b/docs/docs/developer/pr-checklist.md @@ -34,21 +34,23 @@ Run all web checks with `pnpm run check:all` Run all server checks with `pnpm run check:all` ::: -:::info Auto Fix +:::tip Auto Fix You can use `pnpm run __:fix` to potentially correct some issues automatically for `pnpm run format` and `lint`. ::: -## Mobile Checks +## Mobile Checklist -The following commands must be executed from within the mobile app directory of the codebase. +- [ ] `mise //mobile:codegen` (auto-generate files using build_runner) +- [ ] `mise //mobile:lint` (static analysis via Dart Analyzer and DCM) +- [ ] `mise //mobile:format` (formatting via Dart Formatter) +- [ ] `mise //mobile:test` (unit tests) -- [ ] `make build` (auto-generate files using build_runner) -- [ ] `make analyze` (static analysis via Dart Analyzer and DCM) -- [ ] `make format` (formatting via Dart Formatter) -- [ ] `make test` (unit tests) +:::tip +Run all these commands at once with `mise //mobile:checklist` +::: -:::info Auto Fix -You can use `dart fix --apply` and `dcm fix lib` to potentially correct some issues automatically for `make analyze`. +:::tip Auto Fix +You can use `mise //mobile:lint-fix` to potentially correct some issues automatically for `mise //mobile:lint`. ::: ## OpenAPI diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index d7c9edcd31..219c33d1a1 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -17,15 +17,14 @@ make e2e Before you can run the tests, you need to run the following commands _once_: -- `pnpm install` (in `e2e/`) -- `pnpm run build` (in `cli/`) -- `make open-api` (in the project root `/`) +- `pnpm install` +- `pnpm --filter "@immich/*" build` +- `mise //:open-api` Once the test environment is running, the e2e tests can be run via: ```bash -cd e2e/ -pnpm test +mise //e2e:test ``` The tests check various things including: diff --git a/mise.toml b/mise.toml index a88e3bb2b1..f190490f17 100644 --- a/mise.toml +++ b/mise.toml @@ -69,17 +69,15 @@ run = "node ./dist/bin/sync-sql.js" # SDK tasks [tasks."sdk:install"] dir = "packages/sdk" -run = "pnpm install --filter @immich/sdk --frozen-lockfile" +run = "pnpm --filter @immich/sdk install --frozen-lockfile" [tasks."sdk:build"] dir = "packages/sdk" -run = "pnpm run build" +run = "pnpm build" # i18n tasks [tasks."i18n:format"] -dir = "i18n" -run = "pnpm run format" +run = "pnpm format" [tasks."i18n:format-fix"] -dir = "i18n" -run = "pnpm run format:fix" +run = "pnpm format:fix" diff --git a/mobile/makefile b/mobile/makefile index 3a0a263687..5a21287b85 100644 --- a/mobile/makefile +++ b/mobile/makefile @@ -1,55 +1,26 @@ -.PHONY: build watch create_app_icon create_splash build_release_android pigeon test analyze format +.PHONY: build watch create_app_icon create_splash build_release_android pigeon test analyze format migration translation build: - dart run build_runner build --delete-conflicting-outputs -# Remove once auto_route updated to 10.1.0 - dart format lib/routing/router.gr.dart + @printf "This command has been removed. Please use:\n\n mise codegen # or mise //:mobile:codegen:dart from another directory\n\n" >&2 && exit 1 pigeon: - dart run pigeon --input pigeon/native_sync_api.dart - dart run pigeon --input pigeon/local_image_api.dart - dart run pigeon --input pigeon/remote_image_api.dart - dart run pigeon --input pigeon/background_worker_api.dart - dart run pigeon --input pigeon/background_worker_lock_api.dart - dart run pigeon --input pigeon/connectivity_api.dart - dart run pigeon --input pigeon/network_api.dart - dart format lib/platform/native_sync_api.g.dart - dart format lib/platform/local_image_api.g.dart - dart format lib/platform/remote_image_api.g.dart - dart format lib/platform/background_worker_api.g.dart - dart format lib/platform/background_worker_lock_api.g.dart - dart format lib/platform/connectivity_api.g.dart - dart format lib/platform/network_api.g.dart + @printf "This command has been removed. Please use:\n\n mise pigeon # or mise //:mobile:codegen:pigeon from another directory\n\n" >&2 && exit 1 -watch: - dart run build_runner watch --delete-conflicting-outputs - -create_app_icon: - flutter pub run flutter_launcher_icons:main - -create_splash: - flutter pub run flutter_native_splash:create build_release_android: - flutter build appbundle + @printf "This command has been removed. Please use:\n\n mise run build:android # or mise //:mobile:build:android from another directory\n\n" >&2 && exit 1 migration: - dart run drift_dev make-migrations + @printf "This command has been removed. Please use:\n\n mise migration # or mise //:mobile:drift:migration from another directory\n\n" >&2 && exit 1 translation: - pnpm --prefix ../i18n run format:fix - dart run easy_localization:generate -S ../i18n - dart run bin/generate_keys.dart - dart format lib/generated/codegen_loader.g.dart - dart format lib/generated/translations.g.dart + @printf "This command has been removed. Please use:\n\n mise translation # or mise //:mobile:codegen:translation from another directory\n\n" >&2 && exit 1 analyze: - dart analyze --fatal-infos - dcm analyze lib --fatal-style --fatal-warnings + @printf "This command has been removed. Please use:\n\n mise analyze # or mise //:mobile:lint from another directory\n\n" >&2 && exit 1 format: -# Ignore generated files manually until https://github.com/dart-lang/dart_style/issues/864 is resolved - dart format --set-exit-if-changed $$(find lib -name '*.dart' -not \( -name 'generated_plugin_registrant.dart' -o -name '*.g.dart' -o -name '*.drift.dart' \)) + @printf "This command has been removed. Please use:\n\n mise format # or mise //:mobile:format from another directory\n\n" >&2 && exit 1 test: - flutter test + @printf "This command has been removed. Please use:\n\n mise test # or mise //:mobile:test from another directory\n\n" >&2 && exit 1 diff --git a/mobile/mise.toml b/mobile/mise.toml index ed928f2445..89a9f0035c 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -29,12 +29,15 @@ run = "dart run build_runner watch --delete-conflicting-outputs" [tasks."codegen:pigeon"] alias = "pigeon" description = "Generate pigeon platform code" -depends = [ - "pigeon:native-sync", - "pigeon:thumbnail", - "pigeon:background-worker", - "pigeon:background-worker-lock", - "pigeon:connectivity", +run = [ + "dart run pigeon --input pigeon/native_sync_api.dart", + "dart run pigeon --input pigeon/local_image_api.dart", + "dart run pigeon --input pigeon/remote_image_api.dart", + "dart run pigeon --input pigeon/background_worker_api.dart", + "dart run pigeon --input pigeon/background_worker_lock_api.dart", + "dart run pigeon --input pigeon/connectivity_api.dart", + "dart run pigeon --input pigeon/network_api.dart", + "dart format lib/platform/native_sync_api.g.dart lib/platform/local_image_api.g.dart lib/platform/remote_image_api.g.dart lib/platform/background_worker_api.g.dart lib/platform/background_worker_lock_api.g.dart lib/platform/connectivity_api.g.dart lib/platform/network_api.g.dart", ] [tasks."codegen:translation"] @@ -60,13 +63,15 @@ run = "flutter pub run flutter_native_splash:create" description = "Run mobile tests" run = "flutter test" -[tasks.lint] +[tasks.analyze] +alias = "lint" description = "Analyze Dart code" depends = ["analyze:dart", "analyze:dcm"] -[tasks."lint-fix"] +[tasks."analyze-fix"] +alias = "lint-fix" description = "Auto-fix Dart code" -depends = ["analyze:fix:dart", "analyze:fix:dcm"] +depends = ["analyze-fix:dart", "analyze-fix:dcm"] [tasks.format] description = "Format Dart code" @@ -83,75 +88,6 @@ run = "dart run drift_dev make-migrations" # Internal tasks -[tasks."pigeon:native-sync"] -description = "Generate native sync API pigeon code" -hide = true -sources = ["pigeon/native_sync_api.dart"] -outputs = [ - "lib/platform/native_sync_api.g.dart", - "ios/Runner/Sync/Messages.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/sync/Messages.g.kt", -] -run = [ - "dart run pigeon --input pigeon/native_sync_api.dart", - "dart format lib/platform/native_sync_api.g.dart", -] - -[tasks."pigeon:thumbnail"] -description = "Generate thumbnail API pigeon code" -hide = true -sources = ["pigeon/thumbnail_api.dart"] -outputs = [ - "lib/platform/thumbnail_api.g.dart", - "ios/Runner/Images/Thumbnails.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt", -] -run = [ - "dart run pigeon --input pigeon/thumbnail_api.dart", - "dart format lib/platform/thumbnail_api.g.dart", -] - -[tasks."pigeon:background-worker"] -description = "Generate background worker API pigeon code" -hide = true -sources = ["pigeon/background_worker_api.dart"] -outputs = [ - "lib/platform/background_worker_api.g.dart", - "ios/Runner/Background/BackgroundWorker.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.g.kt", -] -run = [ - "dart run pigeon --input pigeon/background_worker_api.dart", - "dart format lib/platform/background_worker_api.g.dart", -] - -[tasks."pigeon:background-worker-lock"] -description = "Generate background worker lock API pigeon code" -hide = true -sources = ["pigeon/background_worker_lock_api.dart"] -outputs = [ - "lib/platform/background_worker_lock_api.g.dart", - "android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorkerLock.g.kt", -] -run = [ - "dart run pigeon --input pigeon/background_worker_lock_api.dart", - "dart format lib/platform/background_worker_lock_api.g.dart", -] - -[tasks."pigeon:connectivity"] -description = "Generate connectivity API pigeon code" -hide = true -sources = ["pigeon/connectivity_api.dart"] -outputs = [ - "lib/platform/connectivity_api.g.dart", - "ios/Runner/Connectivity/Connectivity.g.swift", - "android/app/src/main/kotlin/app/alextran/immich/connectivity/Connectivity.g.kt", -] -run = [ - "dart run pigeon --input pigeon/connectivity_api.dart", - "dart format lib/platform/connectivity_api.g.dart", -] - [tasks."i18n:loader"] description = "Generate i18n loader" hide = true @@ -182,12 +118,23 @@ description = "Run Dart Code Metrics" hide = true run = "dcm analyze lib --fatal-style --fatal-warnings" -[tasks."analyze:fix:dart"] +[tasks."analyze-fix:dart"] description = "Auto-fix Dart analysis" hide = true run = "dart fix --apply" -[tasks."analyze:fix:dcm"] +[tasks."analyze-fix:dcm"] description = "Auto-fix Dart Code Metrics" hide = true run = "dcm fix lib" + + +[tasks.checklist] +run = [ + {task = "codegen:pigeon" }, + {task = "codegen:dart" }, + {task = "codegen:translation" }, + {task = "analyze" }, + {task = "format" }, + {task = "test" }, +] From 91ac56cef285561015385f2babe2618a487fadb4 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 02:53:25 +0700 Subject: [PATCH 35/50] refactor: move timeline config to metadata table (#28227) * migrate timeline config --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/config/app_config.dart | 27 ++++++++++++---- .../domain/models/config/timeline_config.dart | 30 +++++++++++++++++ mobile/lib/domain/models/metadata_key.dart | 11 +++++++ mobile/lib/domain/models/setting.model.dart | 3 -- mobile/lib/domain/models/store.model.dart | 32 ++++--------------- .../domain/services/local_sync.service.dart | 3 +- .../lib/domain/services/timeline.service.dart | 15 +++++---- .../repositories/metadata.repository.dart | 5 +++ .../repositories/sync_api.repository.dart | 7 ---- .../widgets/images/thumbnail_tile.widget.dart | 7 ++-- .../widgets/timeline/timeline.state.dart | 7 ++-- .../widgets/timeline/timeline.widget.dart | 10 +++--- .../infrastructure/timeline.provider.dart | 4 +-- mobile/lib/services/app_settings.service.dart | 14 -------- mobile/lib/utils/migration.dart | 9 ++++++ .../asset_list_group_settings.dart | 16 +++++----- .../asset_list_layout_settings.dart | 14 +++++--- .../asset_list_settings.dart | 12 ++++--- .../image_viewer_quality_setting.dart | 9 +----- .../settings/notification_setting.dart | 15 --------- .../domain/services/store_service_test.dart | 8 ++--- 21 files changed, 135 insertions(+), 123 deletions(-) create mode 100644 mobile/lib/domain/models/config/timeline_config.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index ed6254e168..956d9fd100 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,25 +1,40 @@ import 'package:immich_mobile/domain/models/config/cleanup_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; +import 'package:immich_mobile/domain/models/config/timeline_config.dart'; class AppConfig { final ThemeConfig theme; final CleanupConfig cleanup; final MapConfig map; + final TimelineConfig timeline; - const AppConfig({this.theme = const .new(), this.cleanup = const .new(), this.map = const .new()}); + const AppConfig({ + this.theme = const .new(), + this.cleanup = const .new(), + this.map = const .new(), + this.timeline = const .new(), + }); - AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map}) => - .new(theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, map: map ?? this.map); + AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .new( + theme: theme ?? this.theme, + cleanup: cleanup ?? this.cleanup, + map: map ?? this.map, + timeline: timeline ?? this.timeline, + ); @override bool operator ==(Object other) => identical(this, other) || - (other is AppConfig && other.theme == theme && other.cleanup == cleanup && other.map == map); + (other is AppConfig && + other.theme == theme && + other.cleanup == cleanup && + other.map == map && + other.timeline == timeline); @override - int get hashCode => Object.hash(theme, cleanup, map); + int get hashCode => Object.hash(theme, cleanup, map, timeline); @override - String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map)'; + String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)'; } diff --git a/mobile/lib/domain/models/config/timeline_config.dart b/mobile/lib/domain/models/config/timeline_config.dart new file mode 100644 index 0000000000..4b6b9d5625 --- /dev/null +++ b/mobile/lib/domain/models/config/timeline_config.dart @@ -0,0 +1,30 @@ +import 'package:immich_mobile/domain/models/timeline.model.dart'; + +class TimelineConfig { + final int tilesPerRow; + final GroupAssetsBy groupAssetsBy; + final bool storageIndicator; + + const TimelineConfig({this.tilesPerRow = 4, this.groupAssetsBy = GroupAssetsBy.day, this.storageIndicator = true}); + + TimelineConfig copyWith({int? tilesPerRow, GroupAssetsBy? groupAssetsBy, bool? storageIndicator}) => TimelineConfig( + tilesPerRow: tilesPerRow ?? this.tilesPerRow, + groupAssetsBy: groupAssetsBy ?? this.groupAssetsBy, + storageIndicator: storageIndicator ?? this.storageIndicator, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TimelineConfig && + other.tilesPerRow == tilesPerRow && + other.groupAssetsBy == groupAssetsBy && + other.storageIndicator == storageIndicator); + + @override + int get hashCode => Object.hash(tilesPerRow, groupAssetsBy, storageIndicator); + + @override + String toString() => + 'TimelineConfig(tilesPerRow: $tilesPerRow, groupAssetsBy: $groupAssetsBy, storageIndicator: $storageIndicator)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 00b5a5f675..0d75ec8be3 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/config/system_config.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; enum MetadataDomain { appConfig('config.app'), @@ -23,6 +24,16 @@ enum MetadataKey { themeDynamic(.appConfig, 'theme.dynamic', false), themeColorfulInterface(.appConfig, 'theme.colorfulInterface', true), + // Timeline + timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4), + timelineGroupAssetsBy( + .appConfig, + 'timeline.groupAssetsBy', + GroupAssetsBy.day, + _EnumCodec(GroupAssetsBy.values), + ), + timelineStorageIndicator(.appConfig, 'timeline.storageIndicator', true), + // Log logLevel(.systemConfig, 'log.level', .info, _EnumCodec(LogLevel.values)), diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index 2c46507331..de50ec22f4 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -1,9 +1,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { - tilesPerRow(StoreKey.tilesPerRow, 4), - groupAssetsBy(StoreKey.groupAssetsBy, 0), - showStorageIndicator(StoreKey.storageIndicator, true), loadOriginal(StoreKey.loadOriginal, false), loadOriginalVideo(StoreKey.loadOriginalVideo, false), autoPlayVideo(StoreKey.autoPlayVideo, true), diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 125866a4ee..2720c5d3e6 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -19,29 +19,17 @@ enum StoreKey { backgroundBackup._(14), sslClientCertData._(15), sslClientPasswd._(16), - // user settings from [AppSettingsEnum] below: - loadPreview._(100), - loadOriginal._(101), - tilesPerRow._(103), - dynamicLayout._(104), - groupAssetsBy._(105), uploadErrorNotificationGracePeriod._(106), - backgroundBackupTotalProgress._(107), - backgroundBackupSingleProgress._(108), - storageIndicator._(109), thumbnailCacheSize._(110), imageCacheSize._(111), albumThumbnailCacheSize._(112), selectedAlbumSortOrder._(113), advancedTroubleshooting._(114), preferRemoteImage._(116), - loopVideo._(117), selfSignedCert._(120), - ignoreIcloudAssets._(122), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), customHeaders._(127), - syncAlbums._(131), // Auto endpoint switching @@ -50,31 +38,22 @@ enum StoreKey { localEndpoint._(134), externalEndpointList._(135), - // Video settings - loadOriginalVideo._(136), manageLocalMediaAndroid._(137), - // Read-only Mode settings readonlyModeEnabled._(138), - - autoPlayVideo._(139), albumGridView._(140), + loadOriginal._(101), // Image viewer navigation settings + loopVideo._(117), + loadOriginalVideo._(136), + autoPlayVideo._(139), tapToNavigate._(141), // Experimental stuff - photoManagerCustomFilter._(1000), - betaPromptShown._(1001), - betaTimeline._(1002), enableBackup._(1003), useWifiForUploadVideos._(1004), useWifiForUploadPhotos._(1005), - needBetaMigration._(1006), - // TODO: Remove this after patching open-api - shouldResetSync._(1007), - - // Free up space syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store @@ -87,6 +66,9 @@ enum StoreKey { legacyCleanupKeepAlbumIds._(1010), legacyCleanupCutoffDaysAgo._(1011), legacyCleanupDefaultsInitialized._(1012), + legacyTilesPerRow._(103), + legacyGroupAssetsBy._(105), + legacyStorageIndicator._(109), legacyMapRelativeDate._(119), legacyMapShowFavoriteOnly._(118), legacyMapIncludeArchived._(121), diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 1d9ab1e490..34300dee3d 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -93,8 +93,7 @@ class LocalSyncService { if (CurrentPlatform.isIOS) { // On iOS, we need to full sync albums that are marked as cloud as the delta sync - // does not include changes for cloud albums. If ignoreIcloudAssets is enabled, - // remove the albums from the local database from the previous sync + // does not include changes for cloud albums. final cloudAlbums = deviceAlbums.where((a) => a.isCloud).toLocalAlbums(); for (final album in cloudAlbums) { final dbAlbum = dbAlbums.firstWhereOrNull((a) => a.id == album.id); diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 26e22190f4..5779ee1053 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -5,10 +5,9 @@ import 'package:collection/collection.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; -import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; @@ -40,14 +39,16 @@ enum TimelineOrigin { class TimelineFactory { final DriftTimelineRepository _timelineRepository; - final SettingsService _settingsService; + final MetadataRepository _metadataRepository; - const TimelineFactory({required DriftTimelineRepository timelineRepository, required SettingsService settingsService}) - : _timelineRepository = timelineRepository, - _settingsService = settingsService; + const TimelineFactory({ + required DriftTimelineRepository timelineRepository, + required MetadataRepository metadataRepository, + }) : _timelineRepository = timelineRepository, + _metadataRepository = metadataRepository; GroupAssetsBy get groupBy { - final group = GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)]; + final group = _metadataRepository.appConfig.timeline.groupAssetsBy; // We do not support auto grouping in the new timeline yet, fallback to day grouping return group == GroupAssetsBy.auto ? GroupAssetsBy.day : group; } diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index 2f76ee22ae..676842c8ea 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -127,6 +127,11 @@ extension on MetadataDomain { themeMode: repo._read(.mapThemeMode), withPartners: repo._read(.mapWithPartners), ), + timeline: .new( + tilesPerRow: repo._read(.timelineTilesPerRow), + groupAssetsBy: repo._read(.timelineGroupAssetsBy), + storageIndicator: repo._read(.timelineStorageIndicator), + ), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 366803ee31..d9d262e64f 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -3,9 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:immich_mobile/constants/constants.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/semver.dart'; @@ -38,7 +36,6 @@ class SyncApiRepository { final headers = {'Content-Type': 'application/json', 'Accept': 'application/jsonlines+json'}; - final shouldReset = Store.get(StoreKey.shouldResetSync, false); final request = http.Request('POST', Uri.parse(endpoint)); request.headers.addAll(headers); request.body = jsonEncode( @@ -77,7 +74,6 @@ class SyncApiRepository { ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1, ], - reset: shouldReset, ).toJson(), ); @@ -101,9 +97,6 @@ class SyncApiRepository { throw ApiException(response.statusCode, 'Failed to get sync stream: $errorBody'); } - // Reset after successful stream start - await Store.put(StoreKey.shouldResetSync, false); - await for (final chunk in response.stream.transform(utf8.decoder)) { if (shouldAbort) { break; diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 406ca30820..8720cc4253 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -2,15 +2,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; class ThumbnailTile extends ConsumerStatefulWidget { @@ -61,7 +60,7 @@ class _ThumbnailTileState extends ConsumerState { ); final bool storageIndicator = - ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && widget.showStorageIndicator; + ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator)) && widget.showStorageIndicator; if (!isCurrentAsset) { _hideIndicators = false; diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 1e1d4130f7..7b88800f22 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -1,12 +1,11 @@ import 'dart:math' as math; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; class TimelineArgs { @@ -93,7 +92,7 @@ final timelineSegmentProvider = StreamProvider.autoDispose>((ref) final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); final tileExtent = math.max(0, availableTileWidth) / columnCount; - final groupBy = args.groupBy ?? GroupAssetsBy.values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; + final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy)); final timelineService = ref.watch(timelineServiceProvider); yield* timelineService.watchBuckets().map((buckets) { @@ -102,7 +101,7 @@ final timelineSegmentProvider = StreamProvider.autoDispose>((ref) tileHeight: tileExtent, columnCount: columnCount, spacing: spacing, - groupBy: groupBy, + groupBy: groupBy!, ).generate(); }); }, dependencies: [timelineServiceProvider, timelineArgsProvider]); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 6368bbeeea..8974b20d1a 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -10,7 +10,7 @@ import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; @@ -22,8 +22,8 @@ import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; @@ -74,7 +74,7 @@ class Timeline extends StatelessWidget { (ref) => TimelineArgs( maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight, - columnCount: ref.watch(settingsProvider.select((s) => s.get(Setting.tilesPerRow))), + columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)), showStorageIndicator: showStorageIndicator, withStack: withStack, groupBy: groupBy, @@ -161,7 +161,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _scrollController = ScrollController(onAttach: _restoreAssetPosition); _eventSubscription = EventStream.shared.listen(_onEvent); - final currentTilesPerRow = ref.read(settingsProvider).get(Setting.tilesPerRow); + final currentTilesPerRow = ref.read(appConfigProvider.select((config) => config.timeline.tilesPerRow)); _perRow = currentTilesPerRow; _scaleFactor = 7.0 - _perRow; _baseScaleFactor = _scaleFactor; @@ -459,7 +459,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { _restoreAssetIndex = targetAssetIndex; }); - ref.read(settingsProvider.notifier).set(Setting.tilesPerRow, _perRow); + ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, _perRow); } }; }, diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index 06ec0242b2..9f2fdec519 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; final timelineRepositoryProvider = Provider( @@ -29,7 +29,7 @@ final timelineServiceProvider = Provider( final timelineFactoryProvider = Provider( (ref) => TimelineFactory( timelineRepository: ref.watch(timelineRepositoryProvider), - settingsService: ref.watch(settingsProvider), + metadataRepository: ref.watch(metadataProvider), ), ); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index cbb0745275..a98912228c 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -2,23 +2,12 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { - loadPreview(StoreKey.loadPreview, "loadPreview", true), loadOriginal(StoreKey.loadOriginal, "loadOriginal", false), - tilesPerRow(StoreKey.tilesPerRow, "tilesPerRow", 4), - dynamicLayout(StoreKey.dynamicLayout, "dynamicLayout", false), - groupAssetsBy(StoreKey.groupAssetsBy, "groupBy", 0), uploadErrorNotificationGracePeriod( StoreKey.uploadErrorNotificationGracePeriod, "uploadErrorNotificationGracePeriod", 2, ), - backgroundBackupTotalProgress(StoreKey.backgroundBackupTotalProgress, "backgroundBackupTotalProgress", true), - backgroundBackupSingleProgress( - StoreKey.backgroundBackupSingleProgress, - "backgroundBackupSingleProgress", - false, - ), - storageIndicator(StoreKey.storageIndicator, "storageIndicator", true), thumbnailCacheSize(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000), imageCacheSize(StoreKey.imageCacheSize, "imageCacheSize", 350), albumThumbnailCacheSize(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200), @@ -31,13 +20,10 @@ enum AppSettingsEnum { autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), tapToNavigate(StoreKey.tapToNavigate, "tapToNavigate", false), allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), - ignoreIcloudAssets(StoreKey.ignoreIcloudAssets, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), autoEndpointSwitching(StoreKey.autoEndpointSwitching, null, false), - photoManagerCustomFilter(StoreKey.photoManagerCustomFilter, null, true), - betaTimeline(StoreKey.betaTimeline, null, true), enableBackup(StoreKey.enableBackup, null, false), useCellularForUploadVideos(StoreKey.useWifiForUploadVideos, null, false), useCellularForUploadPhotos(StoreKey.useWifiForUploadPhotos, null, false), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index a0397e5ca4..07df7958a1 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -79,6 +80,14 @@ Future _migrateTo26(Drift drift) async { await migrator.migrateBool(StoreKey.legacyMapIncludeArchived, MetadataKey.mapIncludeArchived); await migrator.migrateEnumIndex(StoreKey.legacyMapThemeMode, MetadataKey.mapThemeMode, ThemeMode.values); await migrator.migrateBool(StoreKey.legacyMapwithPartners, MetadataKey.mapWithPartners); + // Timeline + await migrator.migrateInt(StoreKey.legacyTilesPerRow, MetadataKey.timelineTilesPerRow); + await migrator.migrateEnumIndex( + StoreKey.legacyGroupAssetsBy, + MetadataKey.timelineGroupAssetsBy, + GroupAssetsBy.values, + ); + await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator); await migrator.complete(); } diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart index 42ea3acfc0..b9f81da79e 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_group_settings.dart @@ -1,12 +1,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; @@ -15,18 +16,17 @@ class GroupSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final groupByIndex = useAppSettingsState(AppSettingsEnum.groupAssetsBy); - final groupBy = GroupAssetsBy.values[groupByIndex.value]; + final groupBy = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.groupAssetsBy))); Future updateAppSettings(GroupAssetsBy groupBy) async { - await ref.watch(appSettingsServiceProvider).setSetting(AppSettingsEnum.groupAssetsBy, groupBy.index); + await ref.read(metadataProvider).write(MetadataKey.timelineGroupAssetsBy, groupBy); ref.invalidate(appSettingsServiceProvider); } void changeGroupValue(GroupAssetsBy? value) { if (value != null) { - groupByIndex.value = value.index; - unawaited(updateAppSettings(groupBy)); + groupBy.value = value; + unawaited(updateAppSettings(value)); } } @@ -52,7 +52,7 @@ class GroupSettings extends HookConsumerWidget { value: GroupAssetsBy.auto, ), ], - groupBy: groupBy, + groupBy: groupBy.value, onRadioChanged: changeGroupValue, ), ], diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index 55c8195947..20025286f4 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -1,10 +1,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; @@ -13,7 +14,10 @@ class LayoutSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tilesPerRow = useAppSettingsState(AppSettingsEnum.tilesPerRow); + final tilesPerRow = useState(ref.read(appConfigProvider.select((s) => s.timeline.tilesPerRow))); + useValueChanged(tilesPerRow.value, (_, __) { + ref.read(metadataProvider).write(MetadataKey.timelineTilesPerRow, tilesPerRow.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -29,7 +33,9 @@ class LayoutSettings extends HookConsumerWidget { maxValue: 6, minValue: 2, noDivisons: 4, - onChangeEnd: (_) => ref.invalidate(appSettingsServiceProvider), + onChangeEnd: (value) { + ref.invalidate(appSettingsServiceProvider); + }, ), ], ); diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 82394bdc07..21d751c26f 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -1,10 +1,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/metadata_key.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_group_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_layout_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; @@ -15,13 +16,14 @@ class AssetListSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final showStorageIndicator = useAppSettingsState(AppSettingsEnum.storageIndicator); + final storageIndicator = useValueNotifier(ref.watch(appConfigProvider.select((s) => s.timeline.storageIndicator))); final assetListSetting = [ SettingsSwitchListTile( - valueNotifier: showStorageIndicator, + valueNotifier: storageIndicator, title: 'theme_setting_asset_list_storage_indicator_title'.tr(), - onChanged: (_) { + onChanged: (value) { + ref.read(metadataProvider).write(MetadataKey.timelineStorageIndicator, value); ref.invalidate(appSettingsServiceProvider); ref.invalidate(settingsProvider); }, diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart index e437b82dd4..66c28be22d 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart @@ -3,16 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class ImageViewerQualitySetting extends HookConsumerWidget { const ImageViewerQualitySetting({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final isPreview = useAppSettingsState(AppSettingsEnum.loadPreview); final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal); return Column( @@ -23,12 +22,6 @@ class ImageViewerQualitySetting extends HookConsumerWidget { icon: Icons.image_outlined, subtitle: "setting_image_viewer_help".t(context: context), ), - SettingsSwitchListTile( - valueNotifier: isPreview, - title: "setting_image_viewer_preview_title".t(context: context), - subtitle: "setting_image_viewer_preview_subtitle".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), - ), SettingsSwitchListTile( valueNotifier: isOriginal, title: "setting_image_viewer_original_title".t(context: context), diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index d9eab26bda..18a9749a71 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -8,7 +8,6 @@ import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; -import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:permission_handler/permission_handler.dart'; class NotificationSetting extends HookConsumerWidget { @@ -19,8 +18,6 @@ class NotificationSetting extends HookConsumerWidget { final permissionService = ref.watch(notificationPermissionProvider); final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod); - final totalProgressValue = useAppSettingsState(AppSettingsEnum.backgroundBackupTotalProgress); - final singleProgressValue = useAppSettingsState(AppSettingsEnum.backgroundBackupSingleProgress); final hasPermission = permissionService == PermissionStatus.granted; @@ -60,18 +57,6 @@ class NotificationSetting extends HookConsumerWidget { } }), ), - SettingsSwitchListTile( - enabled: hasPermission, - valueNotifier: totalProgressValue, - title: 'setting_notifications_total_progress_title'.tr(), - subtitle: 'setting_notifications_total_progress_subtitle'.tr(), - ), - SettingsSwitchListTile( - enabled: hasPermission, - valueNotifier: singleProgressValue, - title: 'setting_notifications_single_progress_title'.tr(), - subtitle: 'setting_notifications_single_progress_subtitle'.tr(), - ), SettingsSliderListTile( enabled: hasPermission, valueNotifier: sliderValue, diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index 8ceb1e3c9c..9f6a30eefe 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -10,7 +10,7 @@ import '../../infrastructure/repository.mock.dart'; const _kAccessToken = '#ThisIsAToken'; const _kBackgroundBackup = false; -const _kGroupAssetsBy = 2; +const _kVersion = 2; final _kBackupFailedSince = DateTime.utc(2023); void main() { @@ -31,7 +31,7 @@ void main() { (_) async => [ const StoreDto(StoreKey.accessToken, _kAccessToken), const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup), - const StoreDto(StoreKey.groupAssetsBy, _kGroupAssetsBy), + const StoreDto(StoreKey.version, _kVersion), StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince), ], ); @@ -50,7 +50,7 @@ void main() { verify(() => mockDriftStoreRepo.getAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); - expect(sut.tryGet(StoreKey.groupAssetsBy), _kGroupAssetsBy); + expect(sut.tryGet(StoreKey.version), _kVersion); expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); // Other keys should be null expect(sut.tryGet(StoreKey.currentUser), isNull); @@ -152,7 +152,7 @@ void main() { verify(() => mockDriftStoreRepo.deleteAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), isNull); expect(sut.tryGet(StoreKey.backgroundBackup), isNull); - expect(sut.tryGet(StoreKey.groupAssetsBy), isNull); + expect(sut.tryGet(StoreKey.version), isNull); expect(sut.tryGet(StoreKey.backupFailedSince), isNull); }); }); From 3e1c8aacb19f1453737ea394fa10a3156e191843 Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 12 May 2026 21:56:19 +0200 Subject: [PATCH 36/50] feat(mobile): trash/restore all (#28116) * feat(mobile): trash/restore all * chore: remove themeData variable * chore: filter query by user * refactor --------- Co-authored-by: Alex --- .../repositories/remote_asset.repository.dart | 10 ++ .../presentation/pages/drift_trash.page.dart | 92 +++++++++++++++++++ .../infrastructure/action.provider.dart | 20 ++++ .../repositories/asset_api.repository.dart | 10 ++ mobile/lib/services/action.service.dart | 12 +++ 5 files changed, 144 insertions(+) diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 6d19d17931..7d4e23c22b 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -164,6 +164,16 @@ class RemoteAssetRepository extends DriftDatabaseRepository { }); } + Future emptyTrash(String ownerId) async { + await _db.remoteAssetEntity.deleteWhere((t) => t.deletedAt.isNotNull() & t.ownerId.equals(ownerId)); + } + + Future restoreAllTrash(String ownerId) async { + await (_db.remoteAssetEntity.update()..where((t) => t.deletedAt.isNotNull() & t.ownerId.equals(ownerId))).write( + const RemoteAssetEntityCompanion(deletedAt: Value(null)), + ); + } + Future delete(List ids) { return _db.batch((batch) { for (final id in ids) { diff --git a/mobile/lib/presentation/pages/drift_trash.page.dart b/mobile/lib/presentation/pages/drift_trash.page.dart index a85f69a75e..d21b437efe 100644 --- a/mobile/lib/presentation/pages/drift_trash.page.dart +++ b/mobile/lib/presentation/pages/drift_trash.page.dart @@ -1,13 +1,18 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; @RoutePage() class DriftTrashPage extends StatelessWidget { @@ -36,6 +41,7 @@ class DriftTrashPage extends StatelessWidget { pinned: true, centerTitle: true, elevation: 0, + actions: [const _TrashKebabMenu()], ), topSliverWidgetHeight: 24, topSliverWidget: Consumer( @@ -53,3 +59,89 @@ class DriftTrashPage extends StatelessWidget { ); } } + +class _TrashKebabMenu extends ConsumerWidget { + const _TrashKebabMenu(); + + Future _confirmAndRun( + BuildContext context, + WidgetRef ref, { + required String title, + required String content, + required Future Function(String userId) action, + required String Function(int count) successMsg, + }) async { + await showDialog( + context: context, + builder: (_) => ConfirmDialog( + title: title, + content: content, + onOk: () async { + final user = ref.read(currentUserProvider); + if (user == null) { + return; + } + final result = await action(user.id); + if (!context.mounted) { + return; + } + ImmichToast.show( + context: context, + msg: result.success ? successMsg(result.count) : context.t.scaffold_body_error_occurred, + toastType: result.success ? ToastType.success : ToastType.error, + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuAnchor( + consumeOutsideTap: true, + style: MenuStyle( + backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor), + surfaceTintColor: const WidgetStatePropertyAll(Colors.grey), + elevation: const WidgetStatePropertyAll(4), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + ), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)), + ), + menuChildren: [ + BaseActionButton( + label: context.t.empty_trash, + iconData: Icons.delete_forever_outlined, + onPressed: () => _confirmAndRun( + context, + ref, + title: context.t.empty_trash, + content: context.t.empty_trash_confirmation, + action: ref.read(actionProvider.notifier).emptyTrash, + successMsg: (count) => context.t.assets_permanently_deleted_count(count: count), + ), + menuItem: true, + ), + BaseActionButton( + label: context.t.restore_all, + iconData: Icons.restore_outlined, + onPressed: () => _confirmAndRun( + context, + ref, + title: context.t.restore_all, + content: context.t.assets_restore_confirmation, + action: ref.read(actionProvider.notifier).restoreAllTrash, + successMsg: (count) => context.t.assets_restored_count(count: count), + ), + menuItem: true, + ), + ], + builder: (context, controller, child) { + return IconButton( + icon: const Icon(Icons.more_vert_rounded), + onPressed: () => controller.isOpen ? controller.close() : controller.open(), + ); + }, + ); + } +} diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 0e402575a0..8b3dd7d73e 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -239,6 +239,26 @@ class ActionNotifier extends Notifier { } } + Future emptyTrash(String userId) async { + try { + final count = await _service.emptyTrash(userId); + return ActionResult(count: count, success: true); + } catch (error, stack) { + _logger.severe('Failed to empty trash', error, stack); + return ActionResult(count: 0, success: false, error: error.toString()); + } + } + + Future restoreAllTrash(String userId) async { + try { + final count = await _service.restoreAllTrash(userId); + return ActionResult(count: count, success: true); + } catch (error, stack) { + _logger.severe('Failed to restore all trash assets', error, stack); + return ActionResult(count: 0, success: false, error: error.toString()); + } + } + Future trashRemoteAndDeleteLocal(ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); final localIds = _getLocalIdsForSource(source); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 2943177d60..fdb4e3323b 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -31,6 +31,16 @@ class AssetApiRepository extends ApiRepository { await _trashApi.restoreAssets(BulkIdsDto(ids: ids)); } + Future emptyTrash() async { + final response = await _trashApi.emptyTrash(); + return response?.count ?? 0; + } + + Future restoreAllTrash() async { + final response = await _trashApi.restoreTrash(); + return response?.count ?? 0; + } + Future updateVisibility(List ids, AssetVisibilityEnum visibility) async { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility))); } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 44b070e954..4e51c32f97 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -108,6 +108,18 @@ class ActionService { await _remoteAssetRepository.restoreTrash(ids); } + Future emptyTrash(String userId) async { + final count = await _assetApiRepository.emptyTrash(); + await _remoteAssetRepository.emptyTrash(userId); + return count; + } + + Future restoreAllTrash(String userId) async { + final count = await _assetApiRepository.restoreAllTrash(); + await _remoteAssetRepository.restoreAllTrash(userId); + return count; + } + Future trashRemoteAndDeleteLocal(List remoteIds, List localIds) async { await _assetApiRepository.delete(remoteIds, false); await _remoteAssetRepository.trash(remoteIds); From 79801595dba3e57cb70cc812098059dac839077b Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 04:50:35 +0700 Subject: [PATCH 37/50] refactor: move image config to metadata table (#28228) * migrate image config --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/config/app_config.dart | 19 ++++++++++++++---- .../domain/models/config/image_config.dart | 20 +++++++++++++++++++ mobile/lib/domain/models/metadata_key.dart | 4 ++++ mobile/lib/domain/models/setting.model.dart | 2 -- mobile/lib/domain/models/store.model.dart | 6 ++---- .../repositories/metadata.repository.dart | 1 + .../widgets/images/image_provider.dart | 7 ++++--- .../widgets/images/local_image_provider.dart | 5 ++--- .../widgets/images/remote_image_provider.dart | 5 ++--- mobile/lib/services/app_settings.service.dart | 5 ----- mobile/lib/utils/migration.dart | 3 +++ .../widgets/settings/advanced_settings.dart | 6 +++++- .../image_viewer_quality_setting.dart | 9 ++++++--- 13 files changed, 64 insertions(+), 28 deletions(-) create mode 100644 mobile/lib/domain/models/config/image_config.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index 956d9fd100..942260158b 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/models/config/cleanup_config.dart'; +import 'package:immich_mobile/domain/models/config/image_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart'; @@ -8,19 +9,28 @@ class AppConfig { final CleanupConfig cleanup; final MapConfig map; final TimelineConfig timeline; + final ImageConfig image; const AppConfig({ this.theme = const .new(), this.cleanup = const .new(), this.map = const .new(), this.timeline = const .new(), + this.image = const .new(), }); - AppConfig copyWith({ThemeConfig? theme, CleanupConfig? cleanup, MapConfig? map, TimelineConfig? timeline}) => .new( + AppConfig copyWith({ + ThemeConfig? theme, + CleanupConfig? cleanup, + MapConfig? map, + TimelineConfig? timeline, + ImageConfig? image, + }) => .new( theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, map: map ?? this.map, timeline: timeline ?? this.timeline, + image: image ?? this.image, ); @override @@ -30,11 +40,12 @@ class AppConfig { other.theme == theme && other.cleanup == cleanup && other.map == map && - other.timeline == timeline); + other.timeline == timeline && + other.image == image); @override - int get hashCode => Object.hash(theme, cleanup, map, timeline); + int get hashCode => Object.hash(theme, cleanup, map, timeline, image); @override - String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline)'; + String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image)'; } diff --git a/mobile/lib/domain/models/config/image_config.dart b/mobile/lib/domain/models/config/image_config.dart new file mode 100644 index 0000000000..8410a9010b --- /dev/null +++ b/mobile/lib/domain/models/config/image_config.dart @@ -0,0 +1,20 @@ +class ImageConfig { + final bool preferRemote; + final bool loadOriginal; + + const ImageConfig({this.preferRemote = false, this.loadOriginal = false}); + + ImageConfig copyWith({bool? preferRemote, bool? loadOriginal}) => + ImageConfig(preferRemote: preferRemote ?? this.preferRemote, loadOriginal: loadOriginal ?? this.loadOriginal); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ImageConfig && other.preferRemote == preferRemote && other.loadOriginal == loadOriginal); + + @override + int get hashCode => Object.hash(preferRemote, loadOriginal); + + @override + String toString() => 'ImageConfig(preferRemoteImage: $preferRemote, loadOriginal: $loadOriginal)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 0d75ec8be3..c692d77f6b 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -24,6 +24,10 @@ enum MetadataKey { themeDynamic(.appConfig, 'theme.dynamic', false), themeColorfulInterface(.appConfig, 'theme.colorfulInterface', true), + // Image + imagePreferRemote(.appConfig, 'image.preferRemote', false), + imageLoadOriginal(.appConfig, 'image.loadOriginal', false), + // Timeline timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4), timelineGroupAssetsBy( diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index de50ec22f4..f7cb340ee3 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -1,10 +1,8 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { - loadOriginal(StoreKey.loadOriginal, false), loadOriginalVideo(StoreKey.loadOriginalVideo, false), autoPlayVideo(StoreKey.autoPlayVideo, true), - preferRemoteImage(StoreKey.preferRemoteImage, false), advancedTroubleshooting(StoreKey.advancedTroubleshooting, false), enableBackup(StoreKey.enableBackup, false); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 2720c5d3e6..9244eb3c52 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -20,12 +20,8 @@ enum StoreKey { sslClientCertData._(15), sslClientPasswd._(16), uploadErrorNotificationGracePeriod._(106), - thumbnailCacheSize._(110), - imageCacheSize._(111), - albumThumbnailCacheSize._(112), selectedAlbumSortOrder._(113), advancedTroubleshooting._(114), - preferRemoteImage._(116), selfSignedCert._(120), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), @@ -57,6 +53,8 @@ enum StoreKey { syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store + legacyPreferRemoteImage._(116), + legacyLoadOriginal._(101), legacyPrimaryColor._(128), legacyDynamicTheme._(129), legacyColorfulInterface._(130), diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index 676842c8ea..ef9ad6b8ab 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -132,6 +132,7 @@ extension on MetadataDomain { groupAssetsBy: repo._read(.timelineGroupAssetsBy), storageIndicator: repo._read(.timelineStorageIndicator), ), + image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index ea416d9d71..9364fdd091 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -3,9 +3,8 @@ import 'dart:ui' as ui; import 'package:async/async.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; -import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; @@ -189,4 +188,6 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai } bool _shouldUseLocalAsset(BaseAsset asset) => - asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)) && !asset.isEdited; + asset.hasLocal && + (!asset.hasRemote || !MetadataRepository.instance.appConfig.image.preferRemote) && + !asset.isEdited; diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index ed8289a66a..6376e07405 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -1,9 +1,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/loaders/image_request.dart'; +import 'package:immich_mobile/infrastructure/repositories/metadata.repository.dart'; import 'package:immich_mobile/presentation/widgets/images/animated_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; @@ -105,7 +104,7 @@ class LocalFullImageProvider extends CancellableImageProvider { - loadOriginal(StoreKey.loadOriginal, "loadOriginal", false), uploadErrorNotificationGracePeriod( StoreKey.uploadErrorNotificationGracePeriod, "uploadErrorNotificationGracePeriod", 2, ), - thumbnailCacheSize(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000), - imageCacheSize(StoreKey.imageCacheSize, "imageCacheSize", 350), - albumThumbnailCacheSize(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200), selectedAlbumSortOrder(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), - preferRemoteImage(StoreKey.preferRemoteImage, null, false), loopVideo(StoreKey.loopVideo, "loopVideo", true), loadOriginalVideo(StoreKey.loadOriginalVideo, "loadOriginalVideo", false), autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 07df7958a1..e6d2143468 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -88,6 +88,9 @@ Future _migrateTo26(Drift drift) async { GroupAssetsBy.values, ); await migrator.migrateBool(StoreKey.legacyStorageIndicator, MetadataKey.timelineStorageIndicator); + // Image + await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote); + await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal); await migrator.complete(); } diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index e2502aebae..60557aaaca 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -32,7 +32,11 @@ class AdvancedSettings extends HookConsumerWidget { final isManageMediaSupported = useState(false); final manageMediaAndroidPermission = useState(false); final levelId = useState(ref.read(systemConfigProvider).logLevel.index); - final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage); + final preferRemote = useState(ref.read(appConfigProvider).image.preferRemote); + useValueChanged( + preferRemote.value, + (_, __) => ref.read(metadataProvider).write(.imagePreferRemote, preferRemote.value), + ); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); final logLevel = Level.LEVELS[levelId.value].name; diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart index 66c28be22d..7858033401 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; @@ -12,7 +12,10 @@ class ImageViewerQualitySetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal); + final isOriginal = useState(ref.read(appConfigProvider).image.loadOriginal); + useValueChanged(isOriginal.value, (_, __) { + ref.read(metadataProvider).write(.imageLoadOriginal, isOriginal.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, From 8e3035f7836e0dab3fb4e338c216644b96f01a24 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 05:17:07 +0700 Subject: [PATCH 38/50] chore: run mobile tests in parallel (#28393) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4656ff2646..97bccbc9ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -565,7 +565,7 @@ jobs: run: mise //mobile:codegen:translation - name: Run tests - run: mise //mobile:test -j 1 + run: mise //mobile:test ml-unit-tests: name: Unit Test ML From 6ca54ee722aa9825f7df7000ed53eb1e61ab5110 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 May 2026 21:07:23 -0500 Subject: [PATCH 39/50] feat: display more info in asset viewer (#24630) * feat(mobile): more info for asset viewer * feat(mobile): more info for asset viewer --- i18n/en.json | 2 + .../motion_photo_button.widget.dart | 78 +++++++++++++++++++ .../viewer_kebab_menu.widget.dart | 2 +- .../viewer_top_app_bar.widget.dart | 49 ++++++++++-- mobile/lib/utils/action_button.utils.dart | 4 +- 5 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 6cf5bbeaee..5efd33b8ae 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1403,6 +1403,7 @@ "link_to_oauth": "Link to OAuth", "linked_oauth_account": "Linked OAuth account", "list": "List", + "live": "Live", "loading": "Loading", "loading_search_results_failed": "Loading search results failed", "local": "Local", @@ -1584,6 +1585,7 @@ "month": "Month", "monthly_title_text_date_format": "MMMM y", "more": "More", + "motion": "Motion", "move": "Move", "move_down": "Move down", "move_off_locked_folder": "Move out of locked folder", diff --git a/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart new file mode 100644 index 0000000000..800af23039 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/motion_photo_button.widget.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/platform_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; + +class MotionPhotoPlayButton extends ConsumerWidget { + const MotionPhotoPlayButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset)); + final isPlaying = ref.watch(isPlayingMotionVideoProvider); + final showControls = ref.watch(assetViewerProvider.select((state) => state.showingControls)); + final isShowingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails)); + + if (asset == null || !asset.isMotionPhoto || isShowingDetails) { + return const SizedBox.shrink(); + } + + return IgnorePointer( + ignoring: !showControls, + child: AnimatedOpacity( + opacity: showControls ? 1.0 : 0.0, + duration: Durations.short2, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 60), + child: Center( + child: _MotionButton( + isPlaying: isPlaying, + onPressed: ref.read(isPlayingMotionVideoProvider.notifier).toggle, + ), + ), + ), + ), + ), + ); + } +} + +class _MotionButton extends StatelessWidget { + final bool isPlaying; + final VoidCallback onPressed; + + const _MotionButton({required this.isPlaying, required this.onPressed}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.grey[900]!.withValues(alpha: 0.4), + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: InkWell( + onTap: onPressed, + borderRadius: const BorderRadius.all(Radius.circular(24)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isPlaying ? Icons.motion_photos_pause_outlined : Icons.play_circle_outline_rounded, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 8), + Text( + CurrentPlatform.isAndroid ? 'motion'.t(context: context) : 'live'.t(context: context), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 5a79485daf..308f6a72a3 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); + final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); return MenuAnchor( consumeOutsideTap: true, diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 42d11a8063..3b158c63a8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -1,4 +1,5 @@ import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -10,11 +11,13 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart'; import 'package:immich_mobile/providers/activity.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/timezone.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -95,16 +98,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { ), SafeArea( bottom: false, - child: SizedBox.square( + child: SizedBox( + height: preferredSize.height, child: Theme( data: context.themeData.copyWith(iconTheme: const IconThemeData(size: 22, color: Colors.white)), - child: Row( - children: [ - const _AppBarBackButton(), - const Spacer(), - if (!showingDetails && !isReadonlyModeEnabled) - if (isInLockedView) ...lockedViewActions else ...actions, - ], + child: NavigationToolbar( + centerMiddle: true, + leading: const _AppBarBackButton(), + middle: showingDetails ? null : _AssetInfoTitle(asset: asset), + trailing: !showingDetails && !isReadonlyModeEnabled + ? Row(mainAxisSize: MainAxisSize.min, children: isInLockedView ? lockedViewActions : actions) + : null, ), ), ), @@ -139,3 +143,32 @@ class _AppBarBackButton extends ConsumerWidget { ); } } + +class _AssetInfoTitle extends ConsumerWidget { + final BaseAsset asset; + + const _AssetInfoTitle({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + DateTime dateTime = asset.createdAt.toLocal(); + final currentYear = DateTime.now().year; + final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull; + + if (exifInfo?.dateTimeOriginal != null) { + (dateTime, _) = applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo.timeZone); + } + + final isCurrentYear = dateTime.year == currentYear; + final dateFormatted = isCurrentYear ? DateFormat.MMMd().format(dateTime) : DateFormat.yMMMd().format(dateTime); + final timeFormatted = DateFormat.jm().format(dateTime); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(dateFormatted, style: context.textTheme.labelLarge?.copyWith(color: Colors.white)), + Text(timeFormatted, style: context.textTheme.labelMedium?.copyWith(color: Colors.white70)), + ], + ); + } +} diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 3f79cd498f..d527f3a59e 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -315,7 +315,7 @@ class ActionButtonBuilder { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { final visibleButtons = defaultViewerKebabMenuOrder .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .toList(); @@ -331,7 +331,7 @@ class ActionButtonBuilder { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } - result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); + result.add(type.buildButton(context, buildContext, false, true)); lastGroup = type.kebabMenuGroup; } From f4a4649bbc81d9fba4c99f9b16015331e6e8adcd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 23:49:22 -0400 Subject: [PATCH 40/50] chore(deps): update dependency canvas to v3 (#28376) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 235 +++++++++++++++++--------------------------- pnpm-workspace.yaml | 2 +- 2 files changed, 91 insertions(+), 146 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 157b0746e0..b405a1090d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: injectWorkspacePackages: true overrides: - canvas: 2.11.2 + canvas: 3.2.3 sharp: ^0.34.5 webpackbar: ^7.0.0 @@ -198,7 +198,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages/cli: dependencies: @@ -289,7 +289,7 @@ importers: version: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest-fetch-mock: specifier: ^0.4.0 version: 0.4.5(vitest@4.1.5) @@ -663,7 +663,7 @@ importers: version: 13.15.10 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) eslint: specifier: ^10.0.0 version: 10.2.1(jiti@2.6.1) @@ -717,7 +717,7 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) web: dependencies: @@ -973,7 +973,7 @@ importers: version: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -6182,9 +6182,9 @@ packages: caniuse-lite@1.0.30001790: resolution: {integrity: sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==} - canvas@2.11.2: - resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} - engines: {node: '>=6'} + canvas@3.2.3: + resolution: {integrity: sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==} + engines: {node: ^18.12.0 || >= 20.9.0} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6964,10 +6964,6 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} - decompress-response@4.2.1: - resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==} - engines: {node: '>=8'} - decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -7567,6 +7563,10 @@ packages: resolution: {integrity: sha512-Yn66dSBaWGcUaSbm5Nl4G28rxtceLlWf4PstqJMbLix9sN7w0okWHPEvdudiP56Q5Cjl7v3TLyKKwowUFlbD8g==} engines: {node: '>=20.0.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -7866,6 +7866,9 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} @@ -8591,7 +8594,7 @@ packages: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} peerDependencies: - canvas: 2.11.2 + canvas: 3.2.3 peerDependenciesMeta: canvas: optional: true @@ -9308,10 +9311,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - mimic-response@2.1.0: - resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==} - engines: {node: '>=8'} - mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -9479,6 +9478,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -9552,6 +9554,10 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} @@ -10499,6 +10505,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -11176,8 +11188,8 @@ packages: simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - simple-get@3.1.1: - resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} simple-icons@16.17.0: resolution: {integrity: sha512-bRrGtzM6NLgxeMWmRcfDdrRksECk101lRrCn6jjj6qzUB6lQ+E5smnr52rqS1kLPmbLpS/g6iF463j50M4BT7A==} @@ -11886,6 +11898,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -15799,22 +15814,6 @@ snapshots: '@mapbox/mapbox-gl-rtl-text@0.4.0': {} - '@mapbox/node-pre-gyp@1.0.11': - dependencies: - detect-libc: 2.1.2 - https-proxy-agent: 5.0.1 - make-dir: 3.1.0 - node-fetch: 2.7.0 - nopt: 5.0.0 - npmlog: 5.0.1 - rimraf: 3.0.2 - semver: 7.7.4 - tar: 6.2.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': dependencies: detect-libc: 2.1.2 @@ -17248,7 +17247,7 @@ snapshots: svelte: 5.55.2 optionalDependencies: vite: 8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -17964,7 +17963,7 @@ snapshots: '@vercel/oidc@3.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -17979,7 +17978,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -17995,7 +17994,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@3.2.4': dependencies: @@ -18762,24 +18761,10 @@ snapshots: caniuse-lite@1.0.30001790: {} - canvas@2.11.2: + canvas@3.2.3: dependencies: - '@mapbox/node-pre-gyp': 1.0.11 - nan: 2.26.2 - simple-get: 3.1.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - - canvas@2.11.2(encoding@0.1.13): - dependencies: - '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) - nan: 2.26.2 - simple-get: 3.1.1 - transitivePeerDependencies: - - encoding - - supports-color + node-addon-api: 7.1.1 + prebuild-install: 7.1.3 optional: true ccount@2.0.1: {} @@ -19582,11 +19567,6 @@ snapshots: dependencies: character-entities: 2.0.2 - decompress-response@4.2.1: - dependencies: - mimic-response: 2.1.0 - optional: true - decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -20331,6 +20311,9 @@ snapshots: optionalDependencies: exiftool-vendored.exe: 13.58.0 + expand-template@2.0.3: + optional: true + expect-type@1.3.0: {} exponential-backoff@3.1.3: {} @@ -20416,11 +20399,10 @@ snapshots: fabric@7.3.1: optionalDependencies: - canvas: 2.11.2 - jsdom: 26.1.0(canvas@2.11.2) + canvas: 3.2.3 + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - bufferutil - - encoding - supports-color - utf-8-validate @@ -20712,6 +20694,9 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: + optional: true + github-slugger@1.5.0: {} gl-matrix@3.4.4: {} @@ -21533,7 +21518,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)): + jsdom@26.1.0(canvas@3.2.3): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 @@ -21556,37 +21541,7 @@ snapshots: ws: 8.20.0 xml-name-validator: 5.0.0 optionalDependencies: - canvas: 2.11.2(encoding@0.1.13) - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - - jsdom@26.1.0(canvas@2.11.2): - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.20.0 - xml-name-validator: 5.0.0 - optionalDependencies: - canvas: 2.11.2 + canvas: 3.2.3 transitivePeerDependencies: - bufferutil - supports-color @@ -22589,9 +22544,6 @@ snapshots: mimic-function@5.0.1: {} - mimic-response@2.1.0: - optional: true - mimic-response@3.1.0: {} mimic-response@4.0.0: {} @@ -22745,6 +22697,9 @@ snapshots: nanoid@5.1.9: {} + napi-build-utils@2.0.0: + optional: true + natural-compare-lite@1.4.0: {} natural-compare@1.4.0: {} @@ -22817,6 +22772,11 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abi@3.92.0: + dependencies: + semver: 7.7.4 + optional: true + node-abort-controller@3.1.1: {} node-addon-api@4.3.0: {} @@ -22837,11 +22797,6 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - optional: true - node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -23808,6 +23763,22 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + optional: true + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -24731,9 +24702,9 @@ snapshots: simple-concat@1.0.1: optional: true - simple-get@3.1.1: + simple-get@4.0.1: dependencies: - decompress-response: 4.2.1 + decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 optional: true @@ -25575,6 +25546,11 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + tweetnacl@0.14.5: {} type-check@0.4.0: @@ -25984,9 +25960,9 @@ snapshots: vitest-fetch-mock@0.4.5(vitest@4.1.5): dependencies: - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.2)(happy-dom@20.9.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@3.2.3))(lightningcss@1.32.0)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -26015,7 +25991,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 24.12.2 happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2) + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - jiti - less @@ -26030,7 +26006,7 @@ snapshots: - tsx - yaml - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -26057,42 +26033,11 @@ snapshots: '@types/node': 24.12.2 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - msw - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): - dependencies: - '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.5 - '@vitest/runner': 4.1.5 - '@vitest/snapshot': 4.1.5 - '@vitest/spy': 4.1.5 - '@vitest/utils': 4.1.5 - es-module-lexer: 2.1.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.4 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.1.1 - tinyglobby: 0.2.16 - tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@24.12.2)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 24.12.2 - '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) - happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2) - transitivePeerDependencies: - - msw - - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@2.11.2))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@26.1.0(canvas@3.2.3))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(sass@1.99.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -26119,7 +26064,7 @@ snapshots: '@types/node': 25.6.0 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) happy-dom: 20.9.0 - jsdom: 26.1.0(canvas@2.11.2) + jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 937cbd32f4..57aeb9c7bf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,7 +28,7 @@ onlyBuiltDependencies: - '@tailwindcss/oxide' - bcrypt overrides: - canvas: 2.11.2 + canvas: 3.2.3 sharp: ^0.34.5 # pending docusaurus 3.10.1 webpackbar: ^7.0.0 From 6a8779764965b6c4a52cdb8564ad78bb70975314 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 23:50:23 -0400 Subject: [PATCH 41/50] chore(deps): update terraform cloudflare to v4.52.7 (#28370) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../docs-release/.terraform.lock.hcl | 60 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 60 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 62 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 0869dd28bc..7fdd96847c 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.52.5" - constraints = "4.52.5" + version = "4.52.7" + constraints = "4.52.7" hashes = [ - "h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=", - "h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=", - "h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=", - "h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=", - "h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=", - "h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=", - "h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=", - "h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=", - "h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=", - "h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=", - "h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=", - "h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=", - "h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=", - "h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=", - "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", - "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", - "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", - "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", - "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", - "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072", - "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661", - "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c", + "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=", + "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=", + "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=", + "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=", + "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=", + "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=", + "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=", + "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=", + "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=", + "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=", + "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=", + "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=", + "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", + "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=", + "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", + "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", + "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", + "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285", + "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", - "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", - "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", - "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", - "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", - "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", + "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", + "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", + "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", + "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", + "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", + "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", + "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", + "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", + "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 63347cf67e..7c59cdd2e3 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.52.5" + version = "4.52.7" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 0869dd28bc..7fdd96847c 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.52.5" - constraints = "4.52.5" + version = "4.52.7" + constraints = "4.52.7" hashes = [ - "h1:+rfzF+16ZcWZWnTyW/p1HHTzYbPKX8Zt2nIFtR/+f+E=", - "h1:18bXaaOSq8MWKuMxo/4y7EB7/i7G90y5QsKHZRmkoDo=", - "h1:4vZVOpKeEQZsF2VrARRZFeL37Ed/gD4rRMtfnvWQres=", - "h1:BZOsTF83QPKXTAaYqxPKzdl1KRjk/L2qbPpFjM0w28A=", - "h1:CDuC+HXLvc1z6wkCRsSDcc/+QENIHEtssYshiWg3opA=", - "h1:DE+YFzLnqSe79pI2R4idRGx5QzLdrA7RXvngTkGfZ30=", - "h1:DfaJwH3Ml4yrRbdAY4AcDVy0QTQk5T3A622TXzS/u2E=", - "h1:EIDXP0W3kgIv2pecrFmqtK/DnlqkyckzBzhxKaXU+4A=", - "h1:EV4kYyaOnwGA0bh/3hU6Ezqnt1PFDxopH7i85e48IzY=", - "h1:M0iXabfzamU+MPDi0G9XACpbacFKMakmM+Z9HZ8HrsM=", - "h1:YWmCbGF/KbsrUzcYVBLscwLizidbp95TDQa0N2qpmVo=", - "h1:cxPcCB5gbrpUO1+IXkQYs1YTY50/0IlApCzGea0cwuQ=", - "h1:g6DldikTV2HXUu9uoeNY5FuLufgaYWF4ufgZg7wq62s=", - "h1:oi/Hrx9pwoQ+Z52CBC+rrowVH387EIj0qvnxQgDeI+0=", - "zh:1a3400cb38863b2585968d1876706bcfc67a148e1318a1d325c6c7704adc999b", - "zh:4c5062cb9e9da1676f06ae92b8370186d98976cc4c7030d3cd76df12af54282a", - "zh:52110f493b5f0587ef77a1cfd1a67001fd4c617b14c6502d732ab47352bdc2f7", - "zh:5aa536f9eaeb43823aaf2aa80e7d39b25ef2b383405ed034aa16a28b446a9238", - "zh:5cc39459a1c6be8a918f17054e4fbba573825ed5597dcada588fe99614d98a5b", - "zh:629ae6a7ba298815131da826474d199312d21cec53a4d5ded4fa56a692e6f072", - "zh:719cc7c75dc1d3eb30c22ff5102a017996d9788b948078c7e1c5b3446aeca661", - "zh:8698635a3ca04383c1e93b21d6963346bdae54d27177a48e4b1435b7f731731c", + "h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=", + "h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=", + "h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=", + "h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=", + "h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=", + "h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=", + "h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=", + "h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=", + "h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=", + "h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=", + "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=", + "h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=", + "h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", + "h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=", + "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", + "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", + "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", + "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285", + "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:8a9993f1dcadf1dd6ca43b23348abe374605d29945a2fafc07fb3457644e6a54", - "zh:b1b9a1e6bcc24d5863a664a411d2dc906373ae7a2399d2d65548ce7377057852", - "zh:b270184cdeec277218e84b94cb136fead753da717f9b9dc378e51907f3f00bb0", - "zh:dff2bc10071210181726ce270f954995fe42c696e61e2e8f874021fed02521e5", - "zh:e8e87b40b6a87dc097b0fdc20d3f725cec0d82abc9cc3755c1f89f8f6e8b0036", - "zh:ee964a6573d399a5dd22ce328fb38ca1207797a02248f14b2e4913ee390e7803", + "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", + "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", + "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", + "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", + "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", + "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", + "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", + "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", + "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 63347cf67e..7c59cdd2e3 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.52.5" + version = "4.52.7" } } } From e323e778cdb36696a3ac6517b5f2960112d2709b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 13 May 2026 15:27:25 +0200 Subject: [PATCH 42/50] fix: update server-commands subcommand list (#28402) --- docs/docs/administration/server-commands.md | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index cbd029296f..6938cfadd6 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -13,8 +13,11 @@ The `immich-server` docker image comes preinstalled with an administrative CLI ( | `enable-oauth-login` | Enable OAuth login | | `disable-oauth-login` | Disable OAuth login | | `list-users` | List Immich users | +| `grant-admin` | Grant admin privileges to a user (by email) | +| `revoke-admin` | Revoke admin privileges from a user (by email) | | `version` | Print Immich version | | `change-media-location` | Change database file paths to align with a new media location | +| `schema-check` | Verify database migrations and check for schema drift | ## How to run a command @@ -102,6 +105,22 @@ immich-admin list-users ] ``` +Grant Admin + +``` +immich-admin grant-admin +? Please enter the user email: user@example.com +Admin access has been granted to user@example.com +``` + +Revoke Admin + +``` +immich-admin revoke-admin +? Please enter the user email: user@example.com +Admin access has been revoked from user@example.com +``` + Print Immich Version ``` @@ -126,3 +145,12 @@ immich-admin change-media-location Database file paths updated successfully! 🎉 ... ``` + +Schema Check + +``` +immich-admin schema-check +Migrations are up to date + +No schema drift detected +``` From b6e2ce1f3534cceb6f9f802c7c735650fb3fba7b Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 13 May 2026 09:36:16 -0500 Subject: [PATCH 43/50] fix(mobile): revert drop deprecated deviceAssetId / deviceId from upload fields (#28384) (#28400) * Revert "chore(mobile): drop deprecated deviceAssetId / deviceId from upload fields (#28384)" This reverts commit 571e6a8560fbf4031b30e530196e281b4858bfb4. * chore(mobile): add note on kept deprecated upload fields --------- Co-authored-by: Santo Shakil --- mobile/lib/services/background_upload.service.dart | 4 ++++ mobile/lib/services/foreground_upload.service.dart | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index ab091f3925..b76b9dcd61 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -394,9 +394,13 @@ class BackgroundUploadService { final serverEndpoint = Store.get(StoreKey.serverEndpoint); final url = Uri.parse('$serverEndpoint/assets').toString(); final headers = ApiService.getRequestHeaders(); + final deviceId = Store.get(StoreKey.deviceId); final (baseDirectory, directory, filename) = await Task.split(filePath: file.path); final fieldsMap = { 'filename': originalFileName ?? filename, + // deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818). + 'deviceAssetId': deviceAssetId ?? '', + 'deviceId': deviceId, 'fileCreatedAt': createdAt.toUtc().toIso8601String(), 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'isFavorite': isFavorite?.toString() ?? 'false', diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 2a3ebcb62a..3f7277579f 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/network_capability_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; @@ -319,8 +321,12 @@ class ForegroundUploadService { } final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final deviceId = Store.get(StoreKey.deviceId); final fields = { + // deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818). + 'deviceAssetId': asset.localId!, + 'deviceId': deviceId, 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'isFavorite': asset.isFavorite.toString(), @@ -426,6 +432,9 @@ class ForegroundUploadService { final filename = p.basename(file.path); final fields = { + // deviceAssetId/deviceId required by server v2.7.5 and below (drop in v4.0 per #27818). + 'deviceAssetId': deviceAssetId, + 'deviceId': Store.get(StoreKey.deviceId), 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), 'fileModifiedAt': fileModifiedAt.toUtc().toIso8601String(), 'isFavorite': 'false', From 0a4ed6fd71df412b06c980d7ef1f899b0c9dfe49 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 13 May 2026 21:36:19 +0700 Subject: [PATCH 44/50] refactor: migrate viewer config to metadata table (#28396) * refactor: app metadata * refactor to per row store * cleanup * more test * review changes * more refactor * refactor * migrate primary color * migrate dynamic theme * migrate colorfulInterface * cleanup providers * migrate cleanup * migrate mapconfig * remove unused keys * migrate timeline config * migrate image config * migrate viewer config --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../lib/domain/models/config/app_config.dart | 13 +++++-- .../domain/models/config/viewer_config.dart | 37 +++++++++++++++++++ mobile/lib/domain/models/metadata_key.dart | 6 +++ mobile/lib/domain/models/setting.model.dart | 2 - mobile/lib/domain/models/store.model.dart | 10 ++--- .../repositories/metadata.repository.dart | 6 +++ .../asset_viewer/asset_page.widget.dart | 5 +-- .../asset_viewer/video_viewer.widget.dart | 12 ++---- mobile/lib/services/app_settings.service.dart | 4 -- mobile/lib/utils/migration.dart | 5 +++ .../image_viewer_tap_to_navigate_setting.dart | 11 +++--- .../video_viewer_settings.dart | 25 ++++++++----- .../metadata_repository_test.dart | 3 -- 13 files changed, 96 insertions(+), 43 deletions(-) create mode 100644 mobile/lib/domain/models/config/viewer_config.dart diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index 942260158b..beca1c21e7 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/domain/models/config/image_config.dart'; import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart'; +import 'package:immich_mobile/domain/models/config/viewer_config.dart'; class AppConfig { final ThemeConfig theme; @@ -10,6 +11,7 @@ class AppConfig { final MapConfig map; final TimelineConfig timeline; final ImageConfig image; + final ViewerConfig viewer; const AppConfig({ this.theme = const .new(), @@ -17,6 +19,7 @@ class AppConfig { this.map = const .new(), this.timeline = const .new(), this.image = const .new(), + this.viewer = const .new(), }); AppConfig copyWith({ @@ -25,12 +28,14 @@ class AppConfig { MapConfig? map, TimelineConfig? timeline, ImageConfig? image, + ViewerConfig? viewer, }) => .new( theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, map: map ?? this.map, timeline: timeline ?? this.timeline, image: image ?? this.image, + viewer: viewer ?? this.viewer, ); @override @@ -41,11 +46,13 @@ class AppConfig { other.cleanup == cleanup && other.map == map && other.timeline == timeline && - other.image == image); + other.image == image && + other.viewer == viewer); @override - int get hashCode => Object.hash(theme, cleanup, map, timeline, image); + int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer); @override - String toString() => 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image)'; + String toString() => + 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)'; } diff --git a/mobile/lib/domain/models/config/viewer_config.dart b/mobile/lib/domain/models/config/viewer_config.dart new file mode 100644 index 0000000000..595f2bee5d --- /dev/null +++ b/mobile/lib/domain/models/config/viewer_config.dart @@ -0,0 +1,37 @@ +class ViewerConfig { + final bool loopVideo; + final bool loadOriginalVideo; + final bool autoPlayVideo; + final bool tapToNavigate; + + const ViewerConfig({ + this.loopVideo = true, + this.loadOriginalVideo = false, + this.autoPlayVideo = true, + this.tapToNavigate = false, + }); + + ViewerConfig copyWith({bool? loopVideo, bool? loadOriginalVideo, bool? autoPlayVideo, bool? tapToNavigate}) => + ViewerConfig( + loopVideo: loopVideo ?? this.loopVideo, + loadOriginalVideo: loadOriginalVideo ?? this.loadOriginalVideo, + autoPlayVideo: autoPlayVideo ?? this.autoPlayVideo, + tapToNavigate: tapToNavigate ?? this.tapToNavigate, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ViewerConfig && + other.loopVideo == loopVideo && + other.loadOriginalVideo == loadOriginalVideo && + other.autoPlayVideo == autoPlayVideo && + other.tapToNavigate == tapToNavigate); + + @override + int get hashCode => Object.hash(loopVideo, loadOriginalVideo, autoPlayVideo, tapToNavigate); + + @override + String toString() => + 'ViewerConfig(loopVideo: $loopVideo, loadOriginalVideo: $loadOriginalVideo, autoPlayVideo: $autoPlayVideo, tapToNavigate: $tapToNavigate)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index c692d77f6b..61a3cebc8a 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -28,6 +28,12 @@ enum MetadataKey { imagePreferRemote(.appConfig, 'image.preferRemote', false), imageLoadOriginal(.appConfig, 'image.loadOriginal', false), + // Viewer + viewerLoopVideo(.appConfig, 'viewer.loopVideo', true), + viewerLoadOriginalVideo(.appConfig, 'viewer.loadOriginalVideo', false), + viewerAutoPlayVideo(.appConfig, 'viewer.autoPlayVideo', true), + viewerTapToNavigate(.appConfig, 'viewer.tapToNavigate', false), + // Timeline timelineTilesPerRow(.appConfig, 'timeline.tilesPerRow', 4), timelineGroupAssetsBy( diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index f7cb340ee3..0dc48de3b1 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -1,8 +1,6 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { - loadOriginalVideo(StoreKey.loadOriginalVideo, false), - autoPlayVideo(StoreKey.autoPlayVideo, true), advancedTroubleshooting(StoreKey.advancedTroubleshooting, false), enableBackup(StoreKey.enableBackup, false); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 9244eb3c52..e52e8a0a92 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -40,12 +40,6 @@ enum StoreKey { albumGridView._(140), loadOriginal._(101), - // Image viewer navigation settings - loopVideo._(117), - loadOriginalVideo._(136), - autoPlayVideo._(139), - tapToNavigate._(141), - // Experimental stuff enableBackup._(1003), useWifiForUploadVideos._(1004), @@ -53,6 +47,10 @@ enum StoreKey { syncMigrationStatus._(1013), // Legacy keys that have been migrated to the new metadata store + legacyLoopVideo._(117), + legacyLoadOriginalVideo._(136), + legacyAutoPlayVideo._(139), + legacyTapToNavigate._(141), legacyPreferRemoteImage._(116), legacyLoadOriginal._(101), legacyPrimaryColor._(128), diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index ef9ad6b8ab..d8c8f55898 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -133,6 +133,12 @@ extension on MetadataDomain { storageIndicator: repo._read(.timelineStorageIndicator), ), image: .new(preferRemote: repo._read(.imagePreferRemote), loadOriginal: repo._read(.imageLoadOriginal)), + viewer: .new( + loopVideo: repo._read(.viewerLoopVideo), + loadOriginalVideo: repo._read(.viewerLoadOriginalVideo), + autoPlayVideo: repo._read(.viewerAutoPlayVideo), + tapToNavigate: repo._read(.viewerTapToNavigate), + ), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index a531917e5b..77f693f5c4 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -17,11 +17,10 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widg import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -231,7 +230,7 @@ class _AssetPageState extends ConsumerState { return; } - final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.tapToNavigate); + final tapToNavigate = ref.read(metadataProvider).appConfig.viewer.tapToNavigate; if (!tapToNavigate) { _viewer.toggleControls(); return; diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index bf2ab17425..97ca8ace10 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -3,21 +3,17 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -132,7 +128,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg final remoteId = (videoAsset as RemoteAsset).id; final serverEndpoint = Store.get(StoreKey.serverEndpoint); - final isOriginalVideo = ref.read(settingsProvider).get(Setting.loadOriginalVideo); + final isOriginalVideo = ref.read(metadataProvider).appConfig.viewer.loadOriginalVideo; final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; final String videoUrl = videoAsset.livePhotoVideoId != null ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' @@ -165,7 +161,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg return; } - final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo); + final autoPlayVideo = ref.read(metadataProvider).appConfig.viewer.autoPlayVideo; if (autoPlayVideo || widget.asset.isMotionPhoto) { await _notifier.play(); } @@ -216,7 +212,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg } await _notifier.load(source); - final loopVideo = ref.read(appSettingsServiceProvider).getSetting(AppSettingsEnum.loopVideo); + final loopVideo = ref.read(metadataProvider).appConfig.viewer.loopVideo; await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); await _notifier.setVolume(1); } diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index e04c200989..38d3e028cb 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -10,10 +10,6 @@ enum AppSettingsEnum { selectedAlbumSortOrder(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), - loopVideo(StoreKey.loopVideo, "loopVideo", true), - loadOriginalVideo(StoreKey.loadOriginalVideo, "loadOriginalVideo", false), - autoPlayVideo(StoreKey.autoPlayVideo, "autoPlayVideo", true), - tapToNavigate(StoreKey.tapToNavigate, "tapToNavigate", false), allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index e6d2143468..8f0eb00b16 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -91,6 +91,11 @@ Future _migrateTo26(Drift drift) async { // Image await migrator.migrateBool(StoreKey.legacyPreferRemoteImage, MetadataKey.imagePreferRemote); await migrator.migrateBool(StoreKey.legacyLoadOriginal, MetadataKey.imageLoadOriginal); + // Viewer + await migrator.migrateBool(StoreKey.legacyLoopVideo, MetadataKey.viewerLoopVideo); + await migrator.migrateBool(StoreKey.legacyLoadOriginalVideo, MetadataKey.viewerLoadOriginalVideo); + await migrator.migrateBool(StoreKey.legacyAutoPlayVideo, MetadataKey.viewerAutoPlayVideo); + await migrator.migrateBool(StoreKey.legacyTapToNavigate, MetadataKey.viewerTapToNavigate); await migrator.complete(); } diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart index 759162cab8..5af64b0be9 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart @@ -1,18 +1,20 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class ImageViewerTapToNavigateSetting extends HookConsumerWidget { const ImageViewerTapToNavigateSetting({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final tapToNavigate = useAppSettingsState(AppSettingsEnum.tapToNavigate); + final tapToNavigate = useState(ref.read(appConfigProvider).viewer.tapToNavigate); + useValueChanged(tapToNavigate.value, (_, __) { + ref.read(metadataProvider).write(.viewerTapToNavigate, tapToNavigate.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -22,7 +24,6 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget { valueNotifier: tapToNavigate, title: "setting_image_navigation_enable_title".tr(), subtitle: "setting_image_navigation_enable_subtitle".tr(), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), ], ); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart index c03dcc51b4..8d62544dd4 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart @@ -1,20 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; -import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; class VideoViewerSettings extends HookConsumerWidget { const VideoViewerSettings({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final useLoopVideo = useAppSettingsState(AppSettingsEnum.loopVideo); - final useOriginalVideo = useAppSettingsState(AppSettingsEnum.loadOriginalVideo); - final useAutoPlayVideo = useAppSettingsState(AppSettingsEnum.autoPlayVideo); + final viewer = ref.read(appConfigProvider).viewer; + final useAutoPlayVideo = useState(viewer.autoPlayVideo); + final useLoopVideo = useState(viewer.loopVideo); + final useOriginalVideo = useState(viewer.loadOriginalVideo); + + useValueChanged(useAutoPlayVideo.value, (_, __) { + ref.read(metadataProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value); + }); + useValueChanged(useLoopVideo.value, (_, __) { + ref.read(metadataProvider).write(.viewerLoopVideo, useLoopVideo.value); + }); + useValueChanged(useOriginalVideo.value, (_, __) { + ref.read(metadataProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value); + }); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -27,19 +37,16 @@ class VideoViewerSettings extends HookConsumerWidget { valueNotifier: useAutoPlayVideo, title: "setting_video_viewer_auto_play_title".t(context: context), subtitle: "setting_video_viewer_auto_play_subtitle".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), SettingsSwitchListTile( valueNotifier: useLoopVideo, title: "setting_video_viewer_looping_title".t(context: context), subtitle: "loop_videos_description".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), SettingsSwitchListTile( valueNotifier: useOriginalVideo, title: "setting_video_viewer_original_video_title".t(context: context), subtitle: "setting_video_viewer_original_video_subtitle".t(context: context), - onChanged: (_) => ref.invalidate(appSettingsServiceProvider), ), ], ); diff --git a/mobile/test/medium/repositories/metadata_repository_test.dart b/mobile/test/medium/repositories/metadata_repository_test.dart index 32f613483d..7b185f3bec 100644 --- a/mobile/test/medium/repositories/metadata_repository_test.dart +++ b/mobile/test/medium/repositories/metadata_repository_test.dart @@ -79,7 +79,6 @@ void main() { expect(sut.appConfig.theme.mode, ThemeMode.system); await MetadataRepository.refresh(); - expect(sut.appConfig.theme.mode, ThemeMode.dark); }); @@ -90,7 +89,6 @@ void main() { expect(sut.appConfig.theme.mode, ThemeMode.dark); await MetadataRepository.refresh(); - expect(sut.appConfig.theme.mode, ThemeMode.system); }); @@ -135,5 +133,4 @@ void main() { await expectation; }); }); - } From 024f20ea26e58cb53254654950bff0871932ad27 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Wed, 13 May 2026 16:37:07 +0200 Subject: [PATCH 45/50] chore(web): use DatePicker component from UI lib (#28406) --- i18n/en.json | 5 +- .../shared-components/map/Map.svelte | 16 ++--- web/src/lib/modals/MapSettingsModal.svelte | 65 ++++++++----------- .../modals/PersonEditBirthDateModal.svelte | 27 +++----- web/src/lib/stores/preferences.store.ts | 7 +- 5 files changed, 46 insertions(+), 74 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 5efd33b8ae..697aa7f2fa 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -885,15 +885,13 @@ "cutoff_date_description": "Keep photos from the last…", "cutoff_day": "{count, plural, one {day} other {days}}", "cutoff_year": "{count, plural, one {year} other {years}}", - "daily_title_text_date": "E, MMM dd", - "daily_title_text_date_year": "E, MMM dd, yyyy", "dark": "Dark", "dark_theme": "Switch to dark theme", "date": "Date", "date_after": "Date after", "date_and_time": "Date and Time", "date_before": "Date before", - "date_format": "E, LLL d, y • h:mm a", + "date_of_birth": "Date of birth", "date_of_birth_saved": "Date of birth saved successfully", "date_range": "Date range", "day": "Day", @@ -1583,7 +1581,6 @@ "mobile_app_download_onboarding_note": "Download the companion mobile app using the following options", "model": "Model", "month": "Month", - "monthly_title_text_date_format": "MMMM y", "more": "More", "motion": "Motion", "move": "Move", diff --git a/web/src/lib/components/shared-components/map/Map.svelte b/web/src/lib/components/shared-components/map/Map.svelte index 52686bdd41..4e29506e96 100644 --- a/web/src/lib/components/shared-components/map/Map.svelte +++ b/web/src/lib/components/shared-components/map/Map.svelte @@ -212,16 +212,10 @@ }; } - try { - return { - fileCreatedAfter: dateAfter ? new Date(dateAfter).toISOString() : undefined, - fileCreatedBefore: dateBefore ? new Date(dateBefore).toISOString() : undefined, - }; - } catch { - $mapSettings.dateAfter = ''; - $mapSettings.dateBefore = ''; - return {}; - } + return { + fileCreatedAfter: dateAfter?.toUTC().toISO(), + fileCreatedBefore: dateBefore?.toUTC().toISO(), + }; } async function loadMapMarkers() { @@ -237,7 +231,7 @@ { isArchived: includeArchived || undefined, isFavorite: onlyFavorites || undefined, - fileCreatedAfter: fileCreatedAfter || undefined, + fileCreatedAfter, fileCreatedBefore, withPartners: withPartners || undefined, withSharedAlbums: withSharedAlbums || undefined, diff --git a/web/src/lib/modals/MapSettingsModal.svelte b/web/src/lib/modals/MapSettingsModal.svelte index 4b45def861..4dc0e72685 100644 --- a/web/src/lib/modals/MapSettingsModal.svelte +++ b/web/src/lib/modals/MapSettingsModal.svelte @@ -1,7 +1,6 @@ - {$t('birthdate_set_description')} -
- +
+ + + {$t('birthdate_set_description')} + {#if person.birthDate}
-
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 3c31bef856..016942c572 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -1,3 +1,4 @@ +import type { DateTime } from 'luxon'; import { persisted } from 'svelte-persisted-store'; import { browser } from '$app/environment'; import { defaultLang } from '$lib/constants'; @@ -26,8 +27,8 @@ export interface MapSettings { withPartners: boolean; withSharedAlbums: boolean; relativeDate: string; - dateAfter: string; - dateBefore: string; + dateAfter?: DateTime; + dateBefore?: DateTime; } const defaultMapSettings = { @@ -37,8 +38,6 @@ const defaultMapSettings = { withPartners: false, withSharedAlbums: false, relativeDate: '', - dateAfter: '', - dateBefore: '', }; const persistedObject = (key: string, defaults: T) => From fcea617313048d9da1a68a10b4325931e44b64ed Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 13 May 2026 12:07:35 -0400 Subject: [PATCH 46/50] fix: ignore icc profile make and model (#28412) --- server/src/repositories/metadata.repository.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 57c688cac2..188cc016f1 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; +import { BinaryField, DefaultReadTaskOptions, ExifTool, ReadTaskOptions, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { mimeTypes } from 'src/utils/mime-types'; @@ -89,7 +89,7 @@ export class MetadataRepository { geoTz: (lat, lon) => geotz.find(lat, lon)[0], geolocation: true, // Enable exiftool LFS to parse metadata for files larger than 2GB. - readArgs: ['-api', 'largefilesupport=1'], + readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], taskTimeoutMillis: 2 * 60 * 1000, }); @@ -107,8 +107,8 @@ export class MetadataRepository { } readTags(path: string): Promise { - const args = mimeTypes.isVideo(path) ? ['-ee'] : []; - return this.exiftool.read(path, { readArgs: args }).catch((error) => { + const options: ReadTaskOptions | undefined = mimeTypes.isVideo(path) ? { readArgs: ['-ee'] } : undefined; + return this.exiftool.read(path, options).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); return {}; }) as Promise; From b0315487911d02c8cfb6dda618c6774aee74e331 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Wed, 13 May 2026 22:52:43 +0600 Subject: [PATCH 47/50] fix(mobile): don't block app open on slow validateAccessToken (#28405) * fix(mobile): don't block app open on slow validateAccessToken AuthGuard.onNavigation was async so auto_route awaited the body through validateAccessToken's OS timeout. now it's sync and the validate runs in bg. kicks to login on 401. * fix(mobile): handle re-login race in AuthGuard validate if user logs out + logs back in during a slow validate, the old 401 was logging them out again. now we check the token hasn't changed before redirecting, and dedupe in-flight calls. --------- Co-authored-by: Alex --- mobile/lib/routing/auth_guard.dart | 61 +++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/mobile/lib/routing/auth_guard.dart b/mobile/lib/routing/auth_guard.dart index eaa821c0eb..2fc27be4f4 100644 --- a/mobile/lib/routing/auth_guard.dart +++ b/mobile/lib/routing/auth_guard.dart @@ -15,37 +15,62 @@ class AuthGuard extends AutoRouteGuard { final ApiService _apiService; final AuthService _authService; final _log = Logger("AuthGuard"); + bool _validateInFlight = false; AuthGuard(this._apiService, this._authService); @override - void onNavigation(NavigationResolver resolver, StackRouter router) async { - resolver.next(true); - + void onNavigation(NavigationResolver resolver, StackRouter router) { + // Synchronously check for the access token. auto_route awaits async + // guards, so we keep this function fully sync and validate the token in + // the background — otherwise a slow validateAccessToken() request would + // block the route transition for as long as the OS-level HTTP timeout. try { - // Look in the store for an access token Store.get(StoreKey.accessToken); - - // Validate the access token with the server - final res = await _apiService.authenticationApi.validateAccessToken(); - if (res == null || res.authStatus != true) { - // If the access token is invalid, take user back to login - _log.fine('User token is invalid. Redirecting to login'); - unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData())); - } } on StoreKeyNotFoundException catch (_) { - // If there is no access token, take us to the login page _log.warning('No access token in the store.'); + resolver.next(false); unawaited(router.replaceAll([const LoginRoute()])); return; + } + + resolver.next(true); + unawaited(_validateAccessTokenInBackground(router)); + } + + Future _validateAccessTokenInBackground(StackRouter router) async { + if (_validateInFlight) { + return; + } + final token = Store.tryGet(StoreKey.accessToken); + if (token == null) { + return; + } + _validateInFlight = true; + try { + final res = await _apiService.authenticationApi.validateAccessToken(); + if (res == null || res.authStatus != true) { + // Token may have changed during validation (user logged out + logged in + // again); only act if it still applies to the current session. + if (Store.tryGet(StoreKey.accessToken) != token) { + return; + } + _log.fine('User token is invalid. Redirecting to login'); + await router.replaceAll([const LoginRoute()]); + await _authService.clearLocalData(); + } } on ApiException catch (e) { - // On an unauthorized request, take us to the login page - if (e.code == HttpStatus.unauthorized) { - _log.warning("Unauthorized access token."); - unawaited(router.replaceAll([const LoginRoute()]).then((_) => _authService.clearLocalData())); + if (e.code != HttpStatus.unauthorized) { return; } + if (Store.tryGet(StoreKey.accessToken) != token) { + return; + } + _log.warning("Unauthorized access token."); + await router.replaceAll([const LoginRoute()]); + await _authService.clearLocalData(); } catch (e) { - // Otherwise, this is not fatal, but we still log the warning _log.warning('Error validating access token from server: $e'); + } finally { + _validateInFlight = false; } } } From aeaf84648210dfe16547de4467f7d05758bf2142 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 14 May 2026 04:33:57 +0530 Subject: [PATCH 48/50] chore: cleanup unused store keys (#28415) cleanup unused store keys Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/store.model.dart | 11 ----- mobile/lib/providers/auth.provider.dart | 4 +- mobile/lib/services/app_settings.service.dart | 6 --- mobile/lib/services/auth.service.dart | 1 - .../settings/notification_setting.dart | 32 ------------ .../domain/services/store_service_test.dart | 15 ++---- .../repositories/store_repository_test.dart | 49 ++++++------------- 7 files changed, 21 insertions(+), 97 deletions(-) diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index e52e8a0a92..63281e49da 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -4,25 +4,15 @@ import 'package:immich_mobile/domain/models/user.model.dart'; /// Defines the data type for each value enum StoreKey { version._(0), - assetETag._(1), currentUser._(2), - deviceIdHash._(3), deviceId._(4), - backupFailedSince._(5), - backupRequireWifi._(6), backupRequireCharging._(7), backupTriggerDelay._(8), serverUrl._(10), accessToken._(11), serverEndpoint._(12), - autoBackup._(13), - backgroundBackup._(14), - sslClientCertData._(15), - sslClientPasswd._(16), - uploadErrorNotificationGracePeriod._(106), selectedAlbumSortOrder._(113), advancedTroubleshooting._(114), - selfSignedCert._(120), selectedAlbumSortReverse._(123), enableHapticFeedback._(126), customHeaders._(127), @@ -38,7 +28,6 @@ enum StoreKey { // Read-only Mode settings readonlyModeEnabled._(138), albumGridView._(140), - loadOriginal._(101), // Experimental stuff enableBackup._(1003), diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 825d9e7bc8..4c2a110fde 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -11,12 +11,11 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; +import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; -import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/utils/debug_print.dart'; -import 'package:immich_mobile/utils/hash.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -144,7 +143,6 @@ class AuthNotifier extends StateNotifier { // Due to the flow of the code, this will always happen on first login user = serverUser; await Store.put(StoreKey.deviceId, deviceId); - await Store.put(StoreKey.deviceIdHash, fastHash(deviceId)); } } on ApiException catch (error, stackTrace) { if (error.code == 401) { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 38d3e028cb..1b9a38bc19 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -2,15 +2,9 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; enum AppSettingsEnum { - uploadErrorNotificationGracePeriod( - StoreKey.uploadErrorNotificationGracePeriod, - "uploadErrorNotificationGracePeriod", - 2, - ), selectedAlbumSortOrder(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2), advancedTroubleshooting(StoreKey.advancedTroubleshooting, null, false), manageLocalMediaAndroid(StoreKey.manageLocalMediaAndroid, null, false), - allowSelfSignedSSLCert(StoreKey.selfSignedCert, null, false), selectedAlbumSortReverse(StoreKey.selectedAlbumSortReverse, null, true), enableHapticFeedback(StoreKey.enableHapticFeedback, null, true), syncAlbums(StoreKey.syncAlbums, null, false), diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index 667681e579..1b5eaab715 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -123,7 +123,6 @@ class AuthService { _authRepository.clearLocalData(), Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), - Store.delete(StoreKey.assetETag), Store.delete(StoreKey.autoEndpointSwitching), Store.delete(StoreKey.preferredWifiName), Store.delete(StoreKey.localEndpoint), diff --git a/mobile/lib/widgets/settings/notification_setting.dart b/mobile/lib/widgets/settings/notification_setting.dart index 18a9749a71..46120bb218 100644 --- a/mobile/lib/widgets/settings/notification_setting.dart +++ b/mobile/lib/widgets/settings/notification_setting.dart @@ -3,10 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/notification_permission.provider.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart'; -import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -16,9 +13,6 @@ class NotificationSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final permissionService = ref.watch(notificationPermissionProvider); - - final sliderValue = useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod); - final hasPermission = permissionService == PermissionStatus.granted; openAppNotificationSettings(BuildContext ctx) { @@ -41,8 +35,6 @@ class NotificationSetting extends HookConsumerWidget { ); } - final String formattedValue = _formatSliderValue(sliderValue.value.toDouble()); - final notificationSettings = [ if (!hasPermission) SettingsButtonListTile( @@ -57,32 +49,8 @@ class NotificationSetting extends HookConsumerWidget { } }), ), - SettingsSliderListTile( - enabled: hasPermission, - valueNotifier: sliderValue, - text: 'setting_notifications_notify_failures_grace_period'.tr(namedArgs: {'duration': formattedValue}), - maxValue: 5.0, - noDivisons: 5, - label: formattedValue, - ), ]; return SettingsSubPageScaffold(settings: notificationSettings); } } - -String _formatSliderValue(double v) { - if (v == 0.0) { - return 'setting_notifications_notify_immediately'.tr(); - } else if (v == 1.0) { - return 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '30'}); - } else if (v == 2.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '2'}); - } else if (v == 3.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '8'}); - } else if (v == 4.0) { - return 'setting_notifications_notify_hours'.tr(namedArgs: {'count': '24'}); - } else { - return 'setting_notifications_notify_never'.tr(); - } -} diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index 9f6a30eefe..0a55f8d5c7 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -9,9 +9,8 @@ import 'package:mocktail/mocktail.dart'; import '../../infrastructure/repository.mock.dart'; const _kAccessToken = '#ThisIsAToken'; -const _kBackgroundBackup = false; +const _kEnableBackup = false; const _kVersion = 2; -final _kBackupFailedSince = DateTime.utc(2023); void main() { late StoreService sut; @@ -24,15 +23,13 @@ void main() { // For generics, we need to provide fallback to each concrete type to avoid runtime errors registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.backupTriggerDelay); - registerFallbackValue(StoreKey.backgroundBackup); - registerFallbackValue(StoreKey.backupFailedSince); + registerFallbackValue(StoreKey.enableBackup); when(() => mockDriftStoreRepo.getAll()).thenAnswer( (_) async => [ const StoreDto(StoreKey.accessToken, _kAccessToken), - const StoreDto(StoreKey.backgroundBackup, _kBackgroundBackup), + const StoreDto(StoreKey.enableBackup, _kEnableBackup), const StoreDto(StoreKey.version, _kVersion), - StoreDto(StoreKey.backupFailedSince, _kBackupFailedSince), ], ); when(() => mockDriftStoreRepo.watchAll()).thenAnswer((_) => controller.stream); @@ -49,9 +46,8 @@ void main() { test('Populates the internal cache on init', () { verify(() => mockDriftStoreRepo.getAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), _kAccessToken); - expect(sut.tryGet(StoreKey.backgroundBackup), _kBackgroundBackup); + expect(sut.tryGet(StoreKey.enableBackup), _kEnableBackup); expect(sut.tryGet(StoreKey.version), _kVersion); - expect(sut.tryGet(StoreKey.backupFailedSince), _kBackupFailedSince); // Other keys should be null expect(sut.tryGet(StoreKey.currentUser), isNull); }); @@ -151,9 +147,8 @@ void main() { await sut.clear(); verify(() => mockDriftStoreRepo.deleteAll()).called(1); expect(sut.tryGet(StoreKey.accessToken), isNull); - expect(sut.tryGet(StoreKey.backgroundBackup), isNull); + expect(sut.tryGet(StoreKey.enableBackup), isNull); expect(sut.tryGet(StoreKey.version), isNull); - expect(sut.tryGet(StoreKey.backupFailedSince), isNull); }); }); } diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 806cde9b75..672776b226 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -12,9 +12,8 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' import '../../fixtures/user.stub.dart'; const _kTestAccessToken = "#TestToken"; -final _kTestBackupFailed = DateTime(2025, 2, 20, 11, 45); const _kTestVersion = 10; -const _kTestBackupRequireWifi = false; +const _kTestBackupRequireCharging = false; final _kTestUser = UserStub.admin; Future _populateStore(Drift db) async { @@ -22,16 +21,8 @@ Future _populateStore(Drift db) async { batch.insert( db.storeEntity, StoreEntityCompanion( - id: Value(StoreKey.backupRequireWifi.id), - intValue: const Value(_kTestBackupRequireWifi ? 1 : 0), - stringValue: const Value(null), - ), - ); - batch.insert( - db.storeEntity, - StoreEntityCompanion( - id: Value(StoreKey.backupFailedSince.id), - intValue: Value(_kTestBackupFailed.millisecondsSinceEpoch), + id: Value(StoreKey.backupRequireCharging.id), + intValue: const Value(_kTestBackupRequireCharging ? 1 : 0), stringValue: const Value(null), ), ); @@ -84,20 +75,12 @@ void main() { expect(accessToken, _kTestAccessToken); }); - test('converts datetime', () async { - DateTime? backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); - expect(backupFailedSince, isNull); - await sut.upsert(StoreKey.backupFailedSince, _kTestBackupFailed); - backupFailedSince = await sut.tryGet(StoreKey.backupFailedSince); - expect(backupFailedSince, _kTestBackupFailed); - }); - test('converts bool', () async { - bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isNull); - await sut.upsert(StoreKey.backupRequireWifi, _kTestBackupRequireWifi); - backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, _kTestBackupRequireWifi); + bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isNull); + await sut.upsert(StoreKey.backupRequireCharging, _kTestBackupRequireCharging); + backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, _kTestBackupRequireCharging); }); test('converts user', () async { @@ -115,11 +98,11 @@ void main() { }); test('delete()', () async { - bool? backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isFalse); - await sut.delete(StoreKey.backupRequireWifi); - backupRequireWifi = await sut.tryGet(StoreKey.backupRequireWifi); - expect(backupRequireWifi, isNull); + bool? backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isFalse); + await sut.delete(StoreKey.backupRequireCharging); + backupRequireCharging = await sut.tryGet(StoreKey.backupRequireCharging); + expect(backupRequireCharging, isNull); }); test('deleteAll()', () async { @@ -164,14 +147,12 @@ void main() { emitsInOrder([ [ const StoreDto(StoreKey.version, _kTestVersion), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.backupRequireWifi, _kTestBackupRequireWifi), + const StoreDto(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), const StoreDto(StoreKey.accessToken, _kTestAccessToken), ], [ const StoreDto(StoreKey.version, _kTestVersion + 10), - StoreDto(StoreKey.backupFailedSince, _kTestBackupFailed), - const StoreDto(StoreKey.backupRequireWifi, _kTestBackupRequireWifi), + const StoreDto(StoreKey.backupRequireCharging, _kTestBackupRequireCharging), const StoreDto(StoreKey.accessToken, _kTestAccessToken), ], ]), From 3ff0d47ee37cefaaa50e4dbf5a46e5613570a71e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 14 May 2026 05:16:24 +0530 Subject: [PATCH 49/50] chore: do not cache dart_tool (#28409) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .github/workflows/build-mobile.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index f3f254e4be..bfbc7bd2e2 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -116,7 +116,6 @@ jobs: ~/.gradle/wrapper ~/.android/sdk mobile/android/.gradle - mobile/.dart_tool key: build-mobile-gradle-${{ runner.os }}-main - name: Setup Android SDK @@ -189,7 +188,6 @@ jobs: ~/.gradle/wrapper ~/.android/sdk mobile/android/.gradle - mobile/.dart_tool key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }} build-sign-ios: From 89b3433346d9f3797385125ee4149f2cbaeb42f5 Mon Sep 17 00:00:00 2001 From: racehd <22131782+racehd@users.noreply.github.com> Date: Wed, 13 May 2026 19:54:13 -0400 Subject: [PATCH 50/50] feat(docs): add fixed subnet guide for Synology to prevent firewall issues (#26554) * - Add Set Fixed Subnet section - Add newline after details summary to properly render summary with mdx * pnpm run format --write --------- Co-authored-by: Jason Rasmussen --- docs/docs/install/synology.md | 68 +++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/docs/install/synology.md b/docs/docs/install/synology.md index b86561dbbf..de96886caa 100644 --- a/docs/docs/install/synology.md +++ b/docs/docs/install/synology.md @@ -52,7 +52,7 @@ Scroll to the bottom of the "**Details**" section and find the `IP Address` list ## Step 4 - Configure Firewall Settings -Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS. +Once your project completes the build process, your containers will start. In order to be able to access Immich from your browser, you need to configure the firewall settings for your Synology NAS to allow communication between the Immich containers. Open "**Control Panel**" on your Synology NAS, and select "**Security**". Navigate to "**Firewall**" @@ -74,6 +74,7 @@ Read the [Post Installation](/install/post-install.mdx) steps and [upgrade instr
Updating Immich using Container Manager + Check the post installation and upgrade instructions at the links above before proceeding with this section. ## Step 1. Backup @@ -110,7 +111,7 @@ Go to **Project**, select **Action** then **Build**. This will download, unpack, ## Step 5. Update firewall rule -The default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address. +Without a fixed subnet, the default behavior is to automatically start the containers once installed. If `immich_server` runs for a few seconds and then stops, it may be because the firewall rule no longer matches the server IP address. Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. ![Container IP](../../static/img/synology-container-ip.png) @@ -123,4 +124,67 @@ In this example, the IP addresses mismatch and the firewall rule needs to be edi ![Edit IP](../../static/img/synology-fw-ipedit.png) +To prevent future firewall issues, you may set a fixed subnet. [See Set Fixed Subnet](#set-fixed-subnet) for instructions. + +
+ +
+ Set Fixed Subnet + +Docker by default assigns dynamic subnets to bridge networks which can change when rebuilding containers and can cause firewall rules to break. To avoid this, define a fixed subnet in your `docker-compose.yml`: + +## Step 1. Determine current subnet + +Go to the **Container** section. Click on `immich_server` and scroll down on **General** to find the IP address. +![Container IP](../../static/img/synology-container-ip.png) + +## Step 2. Add network configuration + +Add the following network configuration at the end of your `docker-compose.yml` file: + +```yaml +networks: + immich-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + gateway: 172.20.0.1 +``` + +If your docker container is running on a different subnet then update accordingly. + +## Step 3. Add network to each service + +Add the network to each service (immich-server, immich-machine-learning, redis, database): + +```yaml +services: + immich-server: + # other config options + networks: + - immich-network + + immich-machine-learning: + # other config options + networks: + - immich-network + + redis: + # other config options + networks: + - immich-network + + database: + # other config options + networks: + - immich-network +``` + +Save your changes. Synology will ask if you want to save changes only or rebuild containers. Select rebuild containers. + +## Step 4. Update Firewall Rules, if necessary + +If your firewall rules were not already set for this subnet, the firewall rules will need to be updated. See [Step 4 - Configure Firewall Settings](#step-4---configure-firewall-settings). +