From 755df23b22b2298c22300eb08ce3baf3605342b3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:05:54 +0200 Subject: [PATCH 001/163] chore(deps): update ghcr.io/devcontainers/features/docker-in-docker docker tag to v4 (#30094) --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c9cbf4e7f5..10653afb5a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -83,7 +83,7 @@ } }, "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": { + "ghcr.io/devcontainers/features/docker-in-docker:4": { // https://github.com/devcontainers/features/issues/1466 "moby": false } From e6fff3b15eaa6903eb3d05a5ac88136d5c20ceac Mon Sep 17 00:00:00 2001 From: Roberto Carlos Martinez Arriaga Date: Tue, 21 Jul 2026 08:27:23 -0600 Subject: [PATCH 002/163] feat(web): add maintenance link to command palette (#30016) --- web/src/lib/commands.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 549400d9ca..0673285657 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -25,6 +25,7 @@ import { mdiThemeLightDark, mdiToolboxOutline, mdiTrashCanOutline, + mdiWrench, } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; import { goto } from '$app/navigation'; @@ -66,6 +67,12 @@ export const getPagesProvider = ($t: MessageFormatter) => { icon: mdiServer, onAction: () => goto(Route.systemStatistics()), }, + { + title: $t('admin.maintenance_settings'), + description: $t('admin.maintenance_settings_description'), + icon: mdiWrench, + onAction: () => goto(Route.systemMaintenance()), + }, ].map((route) => ({ ...route, $if: () => authManager.authenticated && authManager.user.isAdmin })); const userPages: ActionItem[] = [ From 59bc81423c96703731186320f76adb3cf9720840 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:37:38 +0200 Subject: [PATCH 003/163] chore(deps): update github-actions (major) (#30095) --- .github/workflows/auto-close.yml | 2 +- .github/workflows/build-mobile.yml | 10 +++---- .github/workflows/cache-cleanup.yml | 2 +- .github/workflows/check-openapi.yml | 4 +-- .github/workflows/cli.yml | 4 +-- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/docs-build.yml | 2 +- .github/workflows/docs-deploy.yml | 2 +- .github/workflows/docs-destroy.yml | 2 +- .github/workflows/fdroid.yml | 2 +- .github/workflows/fix-format.yml | 2 +- .github/workflows/prepare-release.yml | 4 +-- .github/workflows/sdk.yml | 2 +- .github/workflows/static_analysis.yml | 2 +- .github/workflows/test.yml | 38 +++++++++++++-------------- 15 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/workflows/auto-close.yml b/.github/workflows/auto-close.yml index db34e43d98..11f80e8632 100644 --- a/.github/workflows/auto-close.yml +++ b/.github/workflows/auto-close.yml @@ -15,7 +15,7 @@ jobs: outputs: uses_template: ${{ steps.check.outputs.uses_template }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: sparse-checkout: .github/pull_request_template.md sparse-checkout-cone-mode: false diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 42a86431db..39a1a93077 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -87,7 +87,7 @@ jobs: permission-contents: read permission-pull-requests: write - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref }} persist-credentials: false @@ -113,7 +113,7 @@ jobs: - name: Restore Gradle Cache id: cache-gradle-restore - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.gradle/caches @@ -184,7 +184,7 @@ jobs: - name: Save Gradle Cache id: cache-gradle-save - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 if: github.ref == 'refs/heads/main' with: path: | @@ -201,7 +201,7 @@ jobs: contents: read # Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload) if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }} - runs-on: macos-15 + runs-on: macos-26 steps: - id: token @@ -215,7 +215,7 @@ jobs: run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ inputs.ref || github.sha }} persist-credentials: false diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml index 79d6b2b9ec..1b428fc670 100644 --- a/.github/workflows/cache-cleanup.yml +++ b/.github/workflows/cache-cleanup.yml @@ -27,7 +27,7 @@ jobs: permission-actions: write - name: Check out code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 7f2a36c6cb..85fb3ed38c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -20,7 +20,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -38,7 +38,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 74d880ede8..12aae20be1 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -38,7 +38,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -71,7 +71,7 @@ jobs: permission-contents: read - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index af700e390f..e36bbda495 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -51,7 +51,7 @@ jobs: permission-contents: read - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index c32188f7e1..f03cd804a4 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -62,7 +62,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 0352f95bc0..99c2076cc7 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -137,7 +137,7 @@ jobs: permission-pull-requests: write - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml index d0b00b538e..c83764bae3 100644 --- a/.github/workflows/docs-destroy.yml +++ b/.github/workflows/docs-destroy.yml @@ -25,7 +25,7 @@ jobs: permission-pull-requests: write - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/fdroid.yml b/.github/workflows/fdroid.yml index 2d055498dd..8d0467623f 100644 --- a/.github/workflows/fdroid.yml +++ b/.github/workflows/fdroid.yml @@ -14,7 +14,7 @@ jobs: contents: read steps: - name: Checkout pubspec for versionCode - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.release.tag_name }} persist-credentials: false diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 54c1cb1a42..c970d2c071 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -23,7 +23,7 @@ jobs: permission-pull-requests: write - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.event.pull_request.head.ref }} persist-credentials: true diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index bdc13a3579..bd580b72ad 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -61,7 +61,7 @@ jobs: permission-contents: write - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.token.outputs.token }} persist-credentials: true @@ -140,7 +140,7 @@ jobs: permission-actions: read - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.generate-token.outputs.token }} persist-credentials: false diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 97ce581fa5..395cb03fb7 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -23,7 +23,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 8f39906507..adfdde6821 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -58,7 +58,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4fac7a152b..0589fbccec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,7 +85,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -114,7 +114,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -146,7 +146,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -178,7 +178,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -223,7 +223,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -262,7 +262,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -291,7 +291,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -342,7 +342,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -375,7 +375,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false submodules: 'recursive' @@ -412,7 +412,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false submodules: 'recursive' @@ -422,7 +422,7 @@ jobs: uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: '.nvmrc' cache: 'pnpm' @@ -490,7 +490,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false submodules: 'recursive' @@ -500,7 +500,7 @@ jobs: uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: '.nvmrc' cache: 'pnpm' @@ -598,7 +598,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} permission-contents: read - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -637,7 +637,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} permission-contents: read - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -669,7 +669,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -699,7 +699,7 @@ jobs: private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} permission-contents: read - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -722,7 +722,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} @@ -781,7 +781,7 @@ jobs: permission-contents: read - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false token: ${{ steps.token.outputs.token }} From 14a2776453f9078541e69434ec1d826f50ba28c1 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Tue, 21 Jul 2026 21:05:34 +0600 Subject: [PATCH 004/163] fix(mobile): add album picker to the partner view bottom sheet (#30099) --- .../partner_detail_bottom_sheet.widget.dart | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index 5e4dae34bc..c49c805e3d 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart @@ -1,23 +1,76 @@ +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'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; -class PartnerDetailBottomSheet extends ConsumerWidget { +class PartnerDetailBottomSheet extends ConsumerStatefulWidget { const PartnerDetailBottomSheet({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - return const BaseBottomSheet( + ConsumerState createState() => _PartnerDetailBottomSheetState(); +} + +class _PartnerDetailBottomSheetState extends ConsumerState { + late final DraggableScrollableController sheetController; + + @override + void initState() { + super.initState(); + sheetController = DraggableScrollableController(); + } + + @override + void dispose() { + sheetController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Future addToAlbum(RemoteAlbum album) async { + final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album); + + if (!context.mounted) { + return; + } + + if (!result.success) { + ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error); + return; + } + + ImmichToast.show( + context: context, + msg: result.count == 0 + ? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}) + : 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), + ); + } + + Future onKeyboardExpand() { + return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); + } + + return BaseBottomSheet( + controller: sheetController, initialChildSize: 0.25, - maxChildSize: 0.4, + maxChildSize: 0.85, shouldCloseOnMinExtent: false, - actions: [ + actions: const [ ShareActionButton(source: ActionSource.timeline), DownloadActionButton(source: ActionSource.timeline), ], + slivers: [ + const AddToAlbumHeader(), + AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand), + ], ); } } From 0e4572c37bc51e88c43ce06b57e5305f73948432 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:36:27 +0530 Subject: [PATCH 005/163] chore: pin photo_manager to 3.9.0 (#30091) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 993de5c51a..f66d55dc99 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -58,7 +58,7 @@ dependencies: path_provider: ^2.1.5 path_provider_foundation: ^2.6.0 permission_handler: ^11.4.0 - photo_manager: ^3.9.0 + photo_manager: 3.9.0 pinput: ^5.0.2 punycode: ^1.0.0 scroll_date_picker: ^3.8.0 From c8de56c42219a01322055758858fdab7dc1c3321 Mon Sep 17 00:00:00 2001 From: Luis Nachtigall <31982496+LeLunZ@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:31:23 +0200 Subject: [PATCH 006/163] fix(mobile): prevent duplicate album creation during submission (#30003) * fix: prevent duplicate album creation during submission * Nit fix --------- Co-authored-by: Adam Gastineau --- .../presentation/pages/drift_create_album.page.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index a664f46206..0dfae062dc 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -26,6 +26,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { FocusNode albumTitleTextFieldFocusNode = FocusNode(); FocusNode albumDescriptionTextFieldFocusNode = FocusNode(); bool isAlbumTitleTextFieldFocus = false; + bool isCreatingAlbum = false; Set selectedAssets = {}; @override @@ -48,7 +49,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { super.dispose(); } - bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty; + bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty && !isCreatingAlbum; String _getEffectiveTitle() { return albumTitleController.text.isNotEmpty @@ -167,7 +168,12 @@ class _DriftCreateAlbumPageState extends ConsumerState { } Future createAlbum() async { + if (isCreatingAlbum) { + return; + } + onBackgroundTapped(); + setState(() => isCreatingAlbum = true); final title = _getEffectiveTitle().trim(); @@ -187,6 +193,10 @@ class _DriftCreateAlbumPageState extends ConsumerState { if (context.mounted) { ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t()); } + } finally { + if (mounted) { + setState(() => isCreatingAlbum = false); + } } } From 0a7c067b20b43a27c194953eb47ecbd60185cc04 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 21 Jul 2026 08:44:30 -0700 Subject: [PATCH 007/163] chore(mobile): bump maplibre to 0.26.2 in SwiftPM lock (#30108) --- .../Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 83c7948aa3..7f42677909 100644 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809", - "version" : "6.14.0" + "revision" : "84a79bc375a301169390ac110c868f06c857b83f", + "version" : "6.27.0" } }, { From 7a7303acebe6cf264b9edfec57203920cbcafb82 Mon Sep 17 00:00:00 2001 From: Adam Gastineau Date: Tue, 21 Jul 2026 08:45:15 -0700 Subject: [PATCH 008/163] fix(mobile): properly group download tasks for Live Photos (#29952) * fix(mobile): properly group download tasks for Live Photos * Remove completely unused onLivePhotoDownloadStatus * Remove dead code --- .../asset_viewer/download.provider.dart | 87 ------------------- .../infrastructure/action.provider.dart | 27 ------ .../lib/repositories/download.repository.dart | 11 ++- mobile/lib/services/download.service.dart | 60 ++++++++++--- .../infrastructure/action_provider_test.dart | 4 - 5 files changed, 54 insertions(+), 135 deletions(-) diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 25db76b077..37d3392d88 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -1,9 +1,6 @@ -import 'dart:async'; - import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/download/download_state.model.dart'; -import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; import 'package:immich_mobile/services/download.service.dart'; class DownloadStateNotifier extends StateNotifier { @@ -17,79 +14,9 @@ class DownloadStateNotifier extends StateNotifier { taskProgress: {}, ), ) { - _downloadService.onImageDownloadStatus = _downloadImageCallback; - _downloadService.onVideoDownloadStatus = _downloadVideoCallback; - _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; _downloadService.onTaskProgress = _taskProgressCallback; } - void _updateDownloadStatus(String taskId, TaskStatus status) { - if (status == TaskStatus.canceled) { - return; - } - - state = state.copyWith( - taskProgress: {} - ..addAll(state.taskProgress) - ..addAll({ - taskId: DownloadInfo( - progress: state.taskProgress[taskId]?.progress ?? 0, - fileName: state.taskProgress[taskId]?.fileName ?? '', - status: status, - ), - }), - ); - } - - // Download live photo callback - void _downloadLivePhotoCallback(TaskStatusUpdate update) { - _updateDownloadStatus(update.task.taskId, update.status); - - switch (update.status) { - case TaskStatus.complete: - if (update.task.metaData.isEmpty) { - return; - } - final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id; - _downloadService.saveLivePhotos(update.task, livePhotosId); - _onDownloadComplete(update.task.taskId); - break; - - default: - break; - } - } - - // Download image callback - void _downloadImageCallback(TaskStatusUpdate update) { - _updateDownloadStatus(update.task.taskId, update.status); - - switch (update.status) { - case TaskStatus.complete: - _downloadService.saveImageWithPath(update.task); - _onDownloadComplete(update.task.taskId); - break; - - default: - break; - } - } - - // Download video callback - void _downloadVideoCallback(TaskStatusUpdate update) { - _updateDownloadStatus(update.task.taskId, update.status); - - switch (update.status) { - case TaskStatus.complete: - _downloadService.saveVideo(update.task); - _onDownloadComplete(update.task.taskId); - break; - - default: - break; - } - } - void _taskProgressCallback(TaskProgressUpdate update) { // Ignore if the task is canceled or completed if (update.progress == -2 || update.progress == -1) { @@ -110,20 +37,6 @@ class DownloadStateNotifier extends StateNotifier { ); } - void _onDownloadComplete(String id) { - Future.delayed(const Duration(seconds: 2), () { - state = state.copyWith( - taskProgress: {} - ..addAll(state.taskProgress) - ..remove(id), - ); - - if (state.taskProgress.isEmpty) { - state = state.copyWith(showProgress: false); - } - }); - } - void cancelDownload(String id) async { final isCanceled = await _downloadService.cancelDownload(id); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index ed62b9a0e8..d1b2c3af21 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:background_downloader/background_downloader.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -10,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; -import 'package:immich_mobile/models/download/livephotos_medatada.model.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/album.provider.dart'; @@ -23,7 +21,6 @@ import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/action.service.dart'; -import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -48,7 +45,6 @@ class ActionNotifier extends Notifier { final Logger _logger = Logger('ActionNotifier'); late ActionService _service; late ForegroundUploadService _foregroundUploadService; - late DownloadService _downloadService; late AssetService _assetService; ActionNotifier() : super(); @@ -58,29 +54,6 @@ class ActionNotifier extends Notifier { _foregroundUploadService = ref.watch(foregroundUploadServiceProvider); _service = ref.watch(actionServiceProvider); _assetService = ref.watch(assetServiceProvider); - _downloadService = ref.watch(downloadServiceProvider); - _downloadService.onImageDownloadStatus = _downloadImageCallback; - _downloadService.onVideoDownloadStatus = _downloadVideoCallback; - _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; - } - - void _downloadImageCallback(TaskStatusUpdate update) { - if (update.status == TaskStatus.complete) { - _downloadService.saveImageWithPath(update.task); - } - } - - void _downloadVideoCallback(TaskStatusUpdate update) { - if (update.status == TaskStatus.complete) { - _downloadService.saveVideo(update.task); - } - } - - void _downloadLivePhotoCallback(TaskStatusUpdate update) async { - if (update.status == TaskStatus.complete) { - final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id; - unawaited(_downloadService.saveLivePhotos(update.task, livePhotosId)); - } } List _getRemoteIdsForSource(ActionSource source) { diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index c578746a4c..ed1ffd9e3d 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -27,10 +27,12 @@ class DownloadRepository { void Function(TaskStatusUpdate)? onVideoDownloadStatus; - void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; - void Function(TaskProgressUpdate)? onTaskProgress; + // #29900: `taskStatusCallback` is called before the DB has been updated, causing a race between the two Live Photo tasks + // This callback instead listens directly to DB updates + void Function(TaskRecord)? onLivePhotoRecordComplete; + DownloadRepository() { _downloader.registerCallbacks( group: kDownloadGroupImage, @@ -46,9 +48,12 @@ class DownloadRepository { _downloader.registerCallbacks( group: kDownloadGroupLivePhoto, - taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); + + _downloader.database.updates + .where((record) => record.group == kDownloadGroupLivePhoto && record.status == TaskStatus.complete) + .listen((record) => onLivePhotoRecordComplete?.call(record)); } Future> downloadAll(List tasks) { diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index 3f2c36fa7e..b84d6ebfe8 100644 --- a/mobile/lib/services/download.service.dart +++ b/mobile/lib/services/download.service.dart @@ -1,6 +1,8 @@ +import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; @@ -18,14 +20,27 @@ class DownloadService { final Logger _log = Logger("DownloadService"); void Function(TaskStatusUpdate)? onImageDownloadStatus; void Function(TaskStatusUpdate)? onVideoDownloadStatus; - void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; void Function(TaskProgressUpdate)? onTaskProgress; + /// Active Live Photo IDs undergoing saving + final Set _savingLivePhotoIds = {}; + DownloadService(this._fileMediaRepository, this._downloadRepository) { _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; - _downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback; _downloadRepository.onTaskProgress = _onTaskProgressCallback; + _downloadRepository.onLivePhotoRecordComplete = _onLivePhotoRecordComplete; + + unawaited(_savePreviouslyCompletedLivePhotos()); + } + + Future _savePreviouslyCompletedLivePhotos() async { + // Specifically fetch Live Photo video components only, as to not double fetch assets + final records = await _downloadRepository.getLiveVideoTasks(); + final completedIds = records.map((record) => LivePhotosMetadata.fromJson(record.task.metaData).id).toSet(); + for (final id in completedIds) { + await _saveLivePhotos(id); + } } void _onTaskProgressCallback(TaskProgressUpdate update) { @@ -33,18 +48,27 @@ class DownloadService { } void _onImageDownloadCallback(TaskStatusUpdate update) { + if (update.status == TaskStatus.complete) { + unawaited(_saveImageWithPath(update.task)); + } + onImageDownloadStatus?.call(update); } void _onVideoDownloadCallback(TaskStatusUpdate update) { + if (update.status == TaskStatus.complete) { + unawaited(_saveVideo(update.task)); + } + onVideoDownloadStatus?.call(update); } - void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { - onLivePhotoDownloadStatus?.call(update); + void _onLivePhotoRecordComplete(TaskRecord record) async { + final livePhotosId = LivePhotosMetadata.fromJson(record.task.metaData).id; + await _saveLivePhotos(livePhotosId); } - Future saveImageWithPath(Task task) async { + Future _saveImageWithPath(Task task) async { final filePath = await task.filePath(); final title = task.filename; final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; @@ -65,7 +89,7 @@ class DownloadService { } } - Future saveVideo(Task task) async { + Future _saveVideo(Task task) async { final filePath = await task.filePath(); final title = task.filename; final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; @@ -83,14 +107,21 @@ class DownloadService { } } - Future saveLivePhotos(Task task, String livePhotosId) async { + Future _saveLivePhotos(String livePhotosId) async { final records = await _downloadRepository.getLiveVideoTasks(); - if (records.length < 2) { + final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image); + final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video); + + if (imageRecord == null || videoRecord == null) { return false; } - final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image); - final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video); + // Write semaphore for this `livePhotoId` + if (!_savingLivePhotoIds.add(livePhotosId)) { + return false; + } + + final title = imageRecord.task.filename; final imageFilePath = await imageRecord.task.filePath(); final videoFilePath = await videoRecord.task.filePath(); @@ -98,14 +129,14 @@ class DownloadService { final result = await _fileMediaRepository.saveLivePhoto( image: File(imageFilePath), video: File(videoFilePath), - title: task.filename, + title: title, ); return result != null; } on PlatformException catch (error, stack) { // Handle saving MotionPhotos on iOS if (error.code.startsWith('PHPhotosErrorDomain')) { - final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: task.filename); + final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: title); return result != null; } _log.severe("Error saving live photo", error, stack); @@ -125,6 +156,7 @@ class DownloadService { } await _downloadRepository.deleteRecordsWithIds([imageRecord.task.taskId, videoRecord.task.taskId]); + _savingLivePhotoIds.remove(livePhotosId); } } @@ -133,8 +165,8 @@ class DownloadService { } } -TaskRecord _findTaskRecord(List records, String livePhotosId, LivePhotosPart part) { - return records.firstWhere((record) { +TaskRecord? _findTaskRecord(List records, String livePhotosId, LivePhotosPart part) { + return records.firstWhereOrNull((record) { final metadata = LivePhotosMetadata.fromJson(record.task.metaData); return metadata.id == livePhotosId && metadata.part == part; }); diff --git a/mobile/test/providers/infrastructure/action_provider_test.dart b/mobile/test/providers/infrastructure/action_provider_test.dart index be13e58f73..7f5c8d4ec2 100644 --- a/mobile/test/providers/infrastructure/action_provider_test.dart +++ b/mobile/test/providers/infrastructure/action_provider_test.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/action.service.dart'; -import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -20,8 +19,6 @@ class MockActionService extends Mock implements ActionService {} class MockAssetService extends Mock implements AssetService {} -class MockDownloadService extends Mock implements DownloadService {} - class MockForegroundUploadService extends Mock implements ForegroundUploadService {} class MockUserService extends Mock implements UserService {} @@ -67,7 +64,6 @@ void main() { overrides: [ actionServiceProvider.overrideWithValue(actionService), assetServiceProvider.overrideWithValue(assetService), - downloadServiceProvider.overrideWithValue(MockDownloadService()), foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()), currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)), ], From ee4bd3f8336d2dae5e92d549c703f874b561e868 Mon Sep 17 00:00:00 2001 From: Timon Date: Tue, 21 Jul 2026 17:55:46 +0200 Subject: [PATCH 009/163] feat: add album asset event handling (#29008) * feat: add album asset event handling * fix medium tests * patch medium test * collapse in AlbumUpdate --- .../domain/services/remote_album.service.dart | 4 +-- .../repositories/remote_album.repository.dart | 4 +-- .../infrastructure/remote_album.provider.dart | 4 +-- mobile/lib/providers/websocket.provider.dart | 5 +++ server/src/repositories/event.repository.ts | 2 +- .../src/repositories/websocket.repository.ts | 1 + server/src/services/album.service.spec.ts | 9 ++++-- server/src/services/album.service.ts | 31 +++++++++++-------- .../src/services/notification.service.spec.ts | 17 +++++++--- server/src/services/notification.service.ts | 18 +++++++---- .../workflow/workflow-core-plugin.spec.ts | 4 ++- 11 files changed, 64 insertions(+), 35 deletions(-) diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 35a8f899a8..3526c546d9 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -159,8 +159,8 @@ class RemoteAlbumService { return updatedAlbum; } - FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { - return _repository.getDateRange(albumId); + Stream<(DateTime, DateTime)> watchDateRange(String albumId) { + return _repository.watchDateRange(albumId); } Future> getSharedUsers(String albumId) { diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 92dd9b070a..b9cd41c960 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -217,7 +217,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }); } - FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { + Stream<(DateTime, DateTime)> watchDateRange(String albumId) { final query = _db.remoteAlbumAssetEntity.selectOnly() ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) ..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()]) @@ -229,7 +229,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { final minDate = row.read(_db.remoteAssetEntity.createdAt.min()); final maxDate = row.read(_db.remoteAssetEntity.createdAt.max()); return (minDate ?? DateTime.now(), maxDate ?? DateTime.now()); - }).getSingle(); + }).watchSingle(); } Future> getSharedUsers(String albumId) async { diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index a4bbbae818..498fdd50dc 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -313,9 +313,9 @@ class RemoteAlbumNotifier extends Notifier { } } -final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async { +final remoteAlbumDateRangeProvider = StreamProvider.autoDispose.family<(DateTime, DateTime), String>((ref, albumId) { final service = ref.watch(remoteAlbumServiceProvider); - return service.getDateRange(albumId); + return service.watchDateRange(albumId); }); final remoteAlbumSharedUsersProvider = FutureProvider.autoDispose.family, String>((ref, albumId) async { diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 8d9bd5bfe3..fa05f9a4cd 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -103,6 +103,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2); socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1); socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); + socket.on('on_album_update', _handleAlbumUpdate); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { @@ -184,6 +185,10 @@ class WebsocketNotifier extends StateNotifier { unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); } + void _handleAlbumUpdate(dynamic _) { + unawaited(_ref.read(backgroundSyncProvider).syncRemote()); + } + void _handleSyncAssetEditReadyV2(dynamic data) { unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data)); } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 82e67ce97c..416f823952 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -38,7 +38,7 @@ type EventMap = { ConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events - AlbumUpdate: [{ id: string; recipientId: string }]; + AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }]; AlbumInvite: [{ id: string; userId: string; senderName: string }]; // asset events diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index d79e1563e3..3b4328563a 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -37,6 +37,7 @@ export interface ClientEventMap { on_asset_hidden: [string]; on_asset_restore: [string[]]; on_asset_stack_update: string[]; + on_album_update: [string]; on_person_thumbnail: [string]; on_server_version: [ServerVersionResponseDto]; on_config_update: []; diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 6e89c9aca8..a97407b2d9 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -841,7 +841,8 @@ describe(AlbumService.name, () => { expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: album.id, - recipientId: owner.id, + userIds: album.albumUsers.map(({ user }) => user.id), + recipientIds: [owner.id], }); }); @@ -1091,11 +1092,13 @@ describe(AlbumService.name, () => { ]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: album1.id, - recipientId: owner1.id, + userIds: album1.albumUsers.map(({ user }) => user.id), + recipientIds: [owner1.id], }); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: album2.id, - recipientId: owner2.id, + userIds: album2.albumUsers.map(({ user }) => user.id), + recipientIds: [owner2.id], }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index ab86bcf2d4..071c5d5aec 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -190,11 +190,9 @@ export class AlbumService extends BaseService { auth.user.id, ); - const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id); - - for (const recipientId of allUsersExceptUs) { - await this.eventRepository.emit('AlbumUpdate', { id, recipientId }); - } + const userIds = album.albumUsers.map(({ user }) => user.id); + const recipientIds = userIds.filter((userId) => userId !== auth.user.id); + await this.eventRepository.emit('AlbumUpdate', { id, userIds, recipientIds }); } return results; @@ -223,7 +221,7 @@ export class AlbumService extends BaseService { } const albumAssetValues: { albumId: string; assetId: string }[] = []; - const events: { id: string; recipients: string[] }[] = []; + const events: { id: string; userIds: string[]; recipientIds: string[] }[] = []; for (const albumId of allowedAlbumIds) { const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]); const notPresentAssetIds = [...allowedAssetIds.difference(existingAssetIds)]; @@ -246,15 +244,14 @@ export class AlbumService extends BaseService { }, auth.user.id, ); - const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id); - events.push({ id: albumId, recipients: allUsersExceptUs }); + const userIds = album.albumUsers.map(({ user }) => user.id); + const recipientIds = userIds.filter((userId) => userId !== auth.user.id); + events.push({ id: albumId, userIds, recipientIds }); } await this.albumRepository.addAssetIdsToAlbums(albumAssetValues); for (const event of events) { - for (const recipientId of event.recipients) { - await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId }); - } + await this.eventRepository.emit('AlbumUpdate', event); } return results; @@ -271,8 +268,16 @@ export class AlbumService extends BaseService { ); const removedIds = results.filter(({ success }) => success).map(({ id }) => id); - if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) { - await this.albumRepository.updateThumbnails(); + if (removedIds.length > 0) { + if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) { + await this.albumRepository.updateThumbnails(); + } + + await this.eventRepository.emit('AlbumUpdate', { + id, + userIds: album.albumUsers.map(({ user }) => user.id), + recipientIds: [], + }); } return results; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 0d51db7ac8..d6fcefbe6a 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -2,7 +2,6 @@ import { defaults, SystemConfig } from 'src/config'; import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { NotificationService } from 'src/services/notification.service'; -import { INotifyAlbumUpdateJob } from 'src/types'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { AssetFactory } from 'test/factories/asset.factory'; @@ -157,13 +156,21 @@ describe(NotificationService.name, () => { }); describe('onAlbumUpdateEvent', () => { - it('should queue notify album update event', async () => { - await sut.onAlbumUpdate({ id: 'album', recipientId: '42' }); - expect(mocks.job.queue).toHaveBeenCalledWith({ + it('should send a websocket event to every user and queue notify jobs for recipients', async () => { + await sut.onAlbumUpdate({ id: 'album', userIds: ['1', '42'], recipientIds: ['42'] }); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album'); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '42', 'album'); + expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ name: JobName.NotifyAlbumUpdate, data: { id: 'album', recipientId: '42', delay: 300_000 }, }); }); + + it('should not queue email jobs when there are no recipients', async () => { + await sut.onAlbumUpdate({ id: 'album', userIds: ['1'], recipientIds: [] }); + expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album'); + expect(mocks.job.queue).not.toHaveBeenCalled(); + }); }); describe('onAlbumInviteEvent', () => { @@ -522,7 +529,7 @@ describe(NotificationService.name, () => { }); it('should add new recipients for new images if job is already queued', async () => { - await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob); + await sut.onAlbumUpdate({ id: '1', userIds: ['2'], recipientIds: ['2'] }); expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NotifyAlbumUpdate, '1/2'); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.NotifyAlbumUpdate, diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index e688f7dc7f..a650b466ba 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -217,12 +217,18 @@ export class NotificationService extends BaseService { } @OnEvent({ name: 'AlbumUpdate' }) - async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) { - await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`); - await this.jobRepository.queue({ - name: JobName.NotifyAlbumUpdate, - data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs }, - }); + async onAlbumUpdate({ id, userIds, recipientIds }: ArgOf<'AlbumUpdate'>) { + for (const userId of userIds) { + this.websocketRepository.clientSend('on_album_update', userId, id); + } + + for (const recipientId of recipientIds) { + await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`); + await this.jobRepository.queue({ + name: JobName.NotifyAlbumUpdate, + data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs }, + }); + } } @OnEvent({ name: 'AlbumInvite' }) diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index a9a9e174e5..d8830e91e6 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -9,6 +9,7 @@ import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { EventRepository } from 'src/repositories/event.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; @@ -39,7 +40,7 @@ class WorkflowTestContext extends MediumTestContext { UserRepository, WorkflowRepository, ], - mock: [ConfigRepository], + mock: [ConfigRepository, EventRepository], }); } @@ -52,6 +53,7 @@ class WorkflowTestContext extends MediumTestContext { mockData.resourcePaths.corePlugin = '../packages/plugin-core'; mockData.plugins.external.allow = false; this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData); + this.getMock(EventRepository).emit.mockResolvedValue(); this.get(LoggingRepository).setLogLevel(LogLevel.Verbose); await this.sut.onPluginSync(); From 943bfafb7aed269b79ca7c6f5ddb7b55e7888ef6 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Tue, 21 Jul 2026 21:56:50 +0600 Subject: [PATCH 010/163] fix(mobile): show real error when an asset can't be added to an album (#29754) * fix(mobile): show real error when an asset can't be added to an album * fix(mobile): show added toast when an add-to-album partly succeeds * docs(mobile): explain why a partial add-to-album shows added --- .../domain/services/remote_album.service.dart | 6 +- .../add_action_button.widget.dart | 20 +++-- .../favorite_bottom_sheet.widget.dart | 15 +++- .../infrastructure/action.provider.dart | 20 ++++- .../infrastructure/remote_album.provider.dart | 8 +- .../drift_album_api_repository.dart | 2 +- .../drift_album_api_repository_test.dart | 82 +++++++++++++++++++ 7 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 mobile/test/repositories/drift_album_api_repository_test.dart diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 3526c546d9..5141bb455f 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -175,12 +175,12 @@ class RemoteAlbumService { return _repository.getAssets(albumId); } - Future addAssets({required String albumId, required List assetIds}) async { + Future<({int added, int failed})> addAssets({required String albumId, required List assetIds}) async { final album = await _albumApiRepository.addAssets(albumId, assetIds); await _repository.addAssets(albumId, album.added); - return album.added.length; + return (added: album.added.length, failed: album.failed.length); } /// !TODO The name here is not clear as we have addAssets method above, @@ -196,7 +196,7 @@ class RemoteAlbumService { }) async { int addedCount = 0; if (candidates.remoteAssetIds.isNotEmpty) { - addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds); + addedCount += (await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds)).added; } if (candidates.localAssetsToUpload.isNotEmpty) { addedCount += await _uploadAndAddLocals( diff --git a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart index 1ab3f2039d..dc48ed57ec 100644 --- a/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/add_action_button.widget.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.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/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; @@ -154,12 +155,8 @@ class _AddActionButtonState extends ConsumerState { return; } - if (result.count == 0) { - ImmichToast.show( - context: context, - msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}), - ); - } else { + // Only report the failure when nothing was added; if some succeeded we show "added". + if (result.count > 0) { ImmichToast.show( context: context, msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), @@ -167,6 +164,17 @@ class _AddActionButtonState extends ConsumerState { // Refresh the "Appears in" list on the asset's info panel. ref.invalidate(albumsContainingAssetProvider(latest.remoteId!)); + } else if (result.failedCount > 0) { + ImmichToast.show( + context: context, + msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failedCount}), + toastType: ToastType.error, + ); + } else { + ImmichToast.show( + context: context, + msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}), + ); } if (!context.mounted) { diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 8438d5e8ac..bcb9fc6fe3 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -41,7 +41,7 @@ class FavoriteBottomSheet extends ConsumerWidget { } final remoteAssets = selectedAssets.whereType(); - final addedCount = await ref + final result = await ref .read(remoteAlbumProvider.notifier) .addAssets(album.id, remoteAssets.map((e) => e.id).toList()); @@ -52,15 +52,22 @@ class FavoriteBottomSheet extends ConsumerWidget { ); } - if (addedCount != remoteAssets.length) { + // Only report the failure when nothing was added; if some succeeded we show "added". + if (result.added > 0) { ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}), + msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}), + ); + } else if (result.failed > 0) { + ImmichToast.show( + context: context, + msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failed}), + toastType: ToastType.error, ); } else { ImmichToast.show( context: context, - msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}), + msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}), ); } diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index d1b2c3af21..52e2d9e0b9 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -34,11 +34,19 @@ class ActionResult { final bool success; final String? error; final List remoteAssetIds; + final int failedCount; - const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []}); + const ActionResult({ + required this.count, + required this.success, + this.error, + this.remoteAssetIds = const [], + this.failedCount = 0, + }); @override - String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)'; + String toString() => + 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds, failedCount: $failedCount)'; } class ActionNotifier extends Notifier { @@ -366,9 +374,12 @@ class ActionNotifier extends Notifier { final albumNotifier = ref.read(remoteAlbumProvider.notifier); int addedRemote = 0; + int failedRemote = 0; if (remoteIds.isNotEmpty) { try { - addedRemote = await albumNotifier.addAssets(album.id, remoteIds); + final result = await albumNotifier.addAssets(album.id, remoteIds); + addedRemote = result.added; + failedRemote = result.failed; } catch (error, stack) { _logger.severe('Failed to add assets to album ${album.id}', error, stack); return ActionResult(count: 0, success: false, error: error.toString()); @@ -382,7 +393,7 @@ class ActionNotifier extends Notifier { } if (localAssets.isEmpty) { - return ActionResult(count: addedRemote, success: true); + return ActionResult(count: addedRemote, success: true, failedCount: failedRemote); } final uploadResult = await upload( @@ -397,6 +408,7 @@ class ActionNotifier extends Notifier { count: addedRemote + uploadResult.count, success: uploadResult.success, error: uploadResult.error, + failedCount: failedRemote, ); } diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 498fdd50dc..b392f5f401 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -200,12 +200,12 @@ class RemoteAlbumNotifier extends Notifier { return _remoteAlbumService.getAssets(albumId); } - Future addAssets(String albumId, List assetIds) async { - final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds); - if (added > 0) { + Future<({int added, int failed})> addAssets(String albumId, List assetIds) async { + final result = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds); + if (result.added > 0) { await _refreshAlbumInState(albumId); } - return added; + return result; } /// Links a freshly-uploaded local asset to an album using its new remote ID, diff --git a/mobile/lib/repositories/drift_album_api_repository.dart b/mobile/lib/repositories/drift_album_api_repository.dart index ee57352fb1..3a654b7511 100644 --- a/mobile/lib/repositories/drift_album_api_repository.dart +++ b/mobile/lib/repositories/drift_album_api_repository.dart @@ -59,7 +59,7 @@ class DriftAlbumApiRepository extends ApiRepository { for (final dto in response) { if (dto.success) { added.add(dto.id); - } else { + } else if (dto.error.orElse(null) != BulkIdErrorReason.duplicate) { failed.add(dto.id); } } diff --git a/mobile/test/repositories/drift_album_api_repository_test.dart b/mobile/test/repositories/drift_album_api_repository_test.dart new file mode 100644 index 0000000000..93c45c6687 --- /dev/null +++ b/mobile/test/repositories/drift_album_api_repository_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:openapi/api.dart'; + +class _MockAlbumsApi extends Mock implements AlbumsApi {} + +void main() { + late _MockAlbumsApi api; + late DriftAlbumApiRepository repo; + + setUpAll(() { + registerFallbackValue(BulkIdsDto(ids: const [])); + }); + + setUp(() { + api = _MockAlbumsApi(); + repo = DriftAlbumApiRepository(api); + }); + + void stubResponse(List response) { + when( + () => api.addAssetsToAlbum(any(), any(), abortTrigger: any(named: 'abortTrigger')), + ).thenAnswer((_) async => response); + } + + test('no_permission failure surfaces as failed, not added (the #22342 bug)', () async { + stubResponse([ + BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)), + ]); + + final result = await repo.addAssets('album1', ['a1']); + + expect(result.added, isEmpty); + expect(result.failed, ['a1']); + }); + + test('duplicate is neither added nor failed (genuinely already in album)', () async { + stubResponse([ + BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)), + ]); + + final result = await repo.addAssets('album1', ['a1']); + + expect(result.added, isEmpty); + expect(result.failed, isEmpty); + }); + + test('success is added', () async { + stubResponse([BulkIdResponseDto(id: 'a1', success: true)]); + + final result = await repo.addAssets('album1', ['a1']); + + expect(result.added, ['a1']); + expect(result.failed, isEmpty); + }); + + test('not_found and unknown count as failures', () async { + stubResponse([ + BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.notFound)), + BulkIdResponseDto(id: 'a2', success: false, error: const Optional.present(BulkIdErrorReason.unknown)), + ]); + + final result = await repo.addAssets('album1', ['a1', 'a2']); + + expect(result.added, isEmpty); + expect(result.failed, ['a1', 'a2']); + }); + + test('mixed: added kept, no_permission failed, duplicate dropped', () async { + stubResponse([ + BulkIdResponseDto(id: 'ok', success: true), + BulkIdResponseDto(id: 'perm', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)), + BulkIdResponseDto(id: 'dup', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)), + ]); + + final result = await repo.addAssets('album1', ['ok', 'perm', 'dup']); + + expect(result.added, ['ok']); + expect(result.failed, ['perm']); + }); +} From b70cb58bc8923ccde0f31495284e91d28c0aff21 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Tue, 21 Jul 2026 21:28:51 +0530 Subject: [PATCH 011/163] fix(web): improve slideshow controls visibility on bright backgrounds (#29950) Co-authored-by: priyanshuANDcoad --- web/src/lib/components/asset-viewer/SlideshowBar.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/SlideshowBar.svelte b/web/src/lib/components/asset-viewer/SlideshowBar.svelte index e8e6b8061e..0ec5fe7dc0 100644 --- a/web/src/lib/components/asset-viewer/SlideshowBar.svelte +++ b/web/src/lib/components/asset-viewer/SlideshowBar.svelte @@ -176,7 +176,7 @@ {#if showControls}
(isOverControls = true)} onmouseleave={() => (isOverControls = false)} transition:fly={{ duration: 150 }} From d5adfb97dde9fd136fe25abb6eef2023f52c5eb4 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Tue, 21 Jul 2026 22:02:36 +0600 Subject: [PATCH 012/163] fix(mobile): treat wired ethernet as unmetered on ios (#29351) check the metered flags instead of the interface type, so ethernet counts like android already does. --- .../Connectivity/ConnectivityApiImpl.swift | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift index f104314fae..e88b7a0fd6 100644 --- a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift +++ b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift @@ -43,15 +43,16 @@ class ConnectivityApiImpl: ConnectivityApi { capabilities.append(.vpn) } - // Determine if connection is unmetered: - // - Must be on WiFi (not cellular) - // - Must not be expensive (rules out personal hotspot) - // - Must not be constrained (Low Data Mode) - // Note: VPN over cellular should still be considered metered + // Determine if connection is unmetered from the OS metered flags rather than + // the interface type, so wired ethernet (iPhone USB adapters, Apple Silicon + // Macs) is treated as unmetered like Wi-Fi: + // - Not on cellular + // - Not expensive (also rules out cellular and personal hotspot) + // - Not constrained (Low Data Mode) + // Note: VPN over cellular stays metered because the path is still expensive. let isOnCellular = path.usesInterfaceType(.cellular) - let isOnWifi = path.usesInterfaceType(.wifi) - - if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained { + + if !isOnCellular && !path.isExpensive && !path.isConstrained { capabilities.append(.unmetered) } From 73329a8ce8d2463c75aa149903aac9bcc50735cc Mon Sep 17 00:00:00 2001 From: Lorenzo Rota <78847550+lorypota@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:19:13 +0200 Subject: [PATCH 013/163] fix(server): send id_token_hint on OIDC logout (#29720) Co-authored-by: Daniel Dietzler --- server/src/queries/session.repository.sql | 3 +- server/src/repositories/oauth.repository.ts | 4 +- server/src/repositories/session.repository.ts | 2 +- ...4647658615-AddOAuthBearerTokenToSession.ts | 9 +++ server/src/schema/tables/session.table.ts | 3 + server/src/services/auth.service.spec.ts | 61 +++++++++++++++++-- server/src/services/auth.service.ts | 53 +++++++++++----- server/test/factories/session.factory.ts | 1 + server/test/small.factory.ts | 2 +- 9 files changed, 111 insertions(+), 27 deletions(-) create mode 100644 server/src/schema/migrations/1784647658615-AddOAuthBearerTokenToSession.ts diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index a29b6f7cc3..f68f2dbe95 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -4,7 +4,8 @@ select "id", "expiresAt", - "pinExpiresAt" + "pinExpiresAt", + "oauthBearerToken" from "session" where diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index c3f199d246..1a09fff70d 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -83,7 +83,7 @@ export class OAuthRepository { url: string, expectedState: string, codeVerifier: string, - ): Promise<{ profile: OAuthProfile; sid?: string }> { + ): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> { const client = await this.getClient(config); const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined; @@ -111,7 +111,7 @@ export class OAuthRepository { } } - return { profile, sid }; + return { profile, sid, idToken: tokens.id_token }; } catch (error: Error | any) { if (error.message.includes('unexpected JWT alg received')) { this.logger.warn( diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index 451b2263e5..f1cb541b39 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -32,7 +32,7 @@ export class SessionRepository { get(id: string) { return this.db .selectFrom('session') - .select(['id', 'expiresAt', 'pinExpiresAt']) + .select(['id', 'expiresAt', 'pinExpiresAt', 'oauthBearerToken']) .where('id', '=', id) .executeTakeFirst(); } diff --git a/server/src/schema/migrations/1784647658615-AddOAuthBearerTokenToSession.ts b/server/src/schema/migrations/1784647658615-AddOAuthBearerTokenToSession.ts new file mode 100644 index 0000000000..01994a2ab5 --- /dev/null +++ b/server/src/schema/migrations/1784647658615-AddOAuthBearerTokenToSession.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "session" ADD "oauthBearerToken" character varying;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "session" DROP COLUMN "oauthBearerToken";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 950c1eeffd..68381b7f8e 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -55,4 +55,7 @@ export class SessionTable { @Column({ nullable: true, index: true }) oauthSid!: string | null; + + @Column({ nullable: true }) + oauthBearerToken!: string | null; } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index ed5b553a5b..5c76b38542 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -160,7 +160,25 @@ describe(AuthService.name, () => { await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({ successful: true, - redirectUri: 'http://end-session-endpoint', + redirectUri: 'http://end-session-endpoint/', + }); + }); + + it('should include the id token hint for OAuth sessions', async () => { + const auth = AuthFactory.from().session().build(); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); + mocks.session.get.mockResolvedValue({ + id: auth.session!.id, + expiresAt: null, + oauthBearerToken: 'id-token', + pinExpiresAt: null, + }); + mocks.session.delete.mockResolvedValue(); + + await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({ + successful: true, + redirectUri: 'http://end-session-endpoint/?id_token_hint=id-token', }); }); @@ -173,7 +191,7 @@ describe(AuthService.name, () => { await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({ successful: true, - redirectUri: 'http://custom-logout-url', + redirectUri: 'http://custom-logout-url/', }); }); @@ -186,7 +204,7 @@ describe(AuthService.name, () => { await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({ successful: true, - redirectUri: 'http://end-session-endpoint', + redirectUri: 'http://end-session-endpoint/', }); }); @@ -201,6 +219,12 @@ describe(AuthService.name, () => { it('should delete the access token', async () => { const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto; + mocks.session.get.mockResolvedValue({ + id: auth.session!.id, + expiresAt: null, + oauthBearerToken: null, + pinExpiresAt: null, + }); mocks.session.delete.mockResolvedValue(); await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ @@ -722,6 +746,27 @@ describe(AuthService.name, () => { expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); }); + it('should store the OAuth bearer token on the new session', async () => { + const user = UserFactory.create(); + const profile = OAuthProfileFactory.create(); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' }); + mocks.user.getByEmail.mockResolvedValue(user); + mocks.user.update.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' }, + {}, + loginDetails, + ); + + expect(mocks.session.create).toHaveBeenCalledWith( + expect.objectContaining({ oauthSid: 'oauth-sid', oauthBearerToken: 'oauth-bearer-token' }), + ); + }); + it('should normalize the email from the OAuth profile before linking', async () => { const user = UserFactory.create(); const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' }); @@ -1125,6 +1170,7 @@ describe(AuthService.name, () => { mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' }, sid: session.oauthSid ?? undefined, + idToken: session.oauthBearerToken ?? undefined, }); mocks.user.update.mockResolvedValue(user); mocks.session.update.mockResolvedValue(session); @@ -1135,7 +1181,10 @@ describe(AuthService.name, () => { {}, ); - expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid }); + expect(mocks.session.update).toHaveBeenCalledWith(session.id, { + oauthSid: session.oauthSid, + oauthBearerToken: session.oauthBearerToken, + }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' }); }); @@ -1169,7 +1218,7 @@ describe(AuthService.name, () => { expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); }); - it('should unlink an account and remove the oauthSid from the session', async () => { + it('should unlink an account and remove the OAuth data from the session', async () => { const user = UserFactory.create(); const session = SessionFactory.create(); const auth = AuthFactory.from(user).session(session).build(); @@ -1180,7 +1229,7 @@ describe(AuthService.name, () => { await sut.unlink(auth); - expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null }); + expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' }); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 133eef2cf2..d30c912194 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -76,14 +76,17 @@ export class AuthService extends BaseService { } async logout(auth: AuthDto, authType: AuthType): Promise { + let oauthBearerToken: string | undefined; if (auth.session) { + const session = await this.sessionRepository.get(auth.session.id); + oauthBearerToken = session?.oauthBearerToken ?? undefined; await this.sessionRepository.delete(auth.session.id); await this.eventRepository.emit('SessionDelete', { sessionId: auth.session.id }); } return { successful: true, - redirectUri: await this.getLogoutEndpoint(authType), + redirectUri: await this.getLogoutEndpoint(authType, oauthBearerToken), }; } @@ -306,12 +309,11 @@ export class AuthService extends BaseService { } const url = this.resolveRedirectUri(oauth, dto.url); - const { profile, sid: oauthSid } = await this.oauthRepository.getProfileAndOAuthSid( - oauth, - url, - expectedState, - codeVerifier, - ); + const { + profile, + sid: oauthSid, + idToken: oauthBearerToken, + } = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier); const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined; const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); @@ -378,7 +380,7 @@ export class AuthService extends BaseService { await this.syncProfilePicture(user, profile.picture); } - return this.createLoginResponse(user, loginDetails, oauthSid); + return this.createLoginResponse(user, loginDetails, oauthSid, oauthBearerToken); } private async syncProfilePicture(user: UserAdmin, url: string) { @@ -419,6 +421,7 @@ export class AuthService extends BaseService { const { profile: { sub: oauthId }, sid, + idToken, } = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier); const duplicate = await this.userRepository.getByOAuthId(oauthId); if (duplicate && duplicate.id !== auth.user.id) { @@ -426,8 +429,11 @@ export class AuthService extends BaseService { throw new BadRequestException('This OAuth account has already been linked to another user.'); } - if (auth.session && sid) { - await this.sessionRepository.update(auth.session.id, { oauthSid: sid }); + if (auth.session && (sid || idToken)) { + await this.sessionRepository.update(auth.session.id, { + oauthSid: sid, + oauthBearerToken: idToken, + }); } const user = await this.userRepository.update(auth.user.id, { oauthId }); @@ -436,14 +442,14 @@ export class AuthService extends BaseService { async unlink(auth: AuthDto): Promise { if (auth.session) { - await this.sessionRepository.update(auth.session.id, { oauthSid: null }); + await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null }); } const user = await this.userRepository.update(auth.user.id, { oauthId: '' }); return mapUserAdmin(user); } - private async getLogoutEndpoint(authType: AuthType): Promise { + private async getLogoutEndpoint(authType: AuthType, oauthBearerToken?: string | null): Promise { if (authType !== AuthType.OAuth) { return LOGIN_URL; } @@ -453,11 +459,20 @@ export class AuthService extends BaseService { return LOGIN_URL; } - if (config.oauth.endSessionEndpoint) { - return config.oauth.endSessionEndpoint; + const endSessionEndpoint = + config.oauth.endSessionEndpoint || (await this.oauthRepository.getLogoutEndpoint(config.oauth)); + + if (!endSessionEndpoint) { + return LOGIN_URL; } - return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL; + const url = new URL(endSessionEndpoint); + + if (oauthBearerToken) { + url.searchParams.set('id_token_hint', oauthBearerToken); + } + + return url.href; } private getBearerToken(headers: IncomingHttpHeaders): string | null { @@ -599,7 +614,12 @@ export class AuthService extends BaseService { await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null }); } - private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) { + private async createLoginResponse( + user: UserAdmin, + loginDetails: LoginDetails, + oauthSid?: string, + oauthBearerToken?: string, + ) { const token = this.cryptoRepository.randomBytesAsText(32); const hashed = this.cryptoRepository.hashSha256(token); @@ -610,6 +630,7 @@ export class AuthService extends BaseService { appVersion: loginDetails.appVersion, userId: user.id, oauthSid: oauthSid ?? null, + oauthBearerToken: oauthBearerToken ?? null, }); return mapLoginResponse(user, token); diff --git a/server/test/factories/session.factory.ts b/server/test/factories/session.factory.ts index 44a25edcfa..188b8b638c 100644 --- a/server/test/factories/session.factory.ts +++ b/server/test/factories/session.factory.ts @@ -26,6 +26,7 @@ export class SessionFactory { updatedAt: newDate(), userId: newUuid(), oauthSid: newUuid(), + oauthBearerToken: 'oauth-bearer-token', ...dto, }); } diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 1c17d955a9..ae83727d70 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -22,7 +22,7 @@ const authFactory = ({ user, }: { apiKey?: Partial; - session?: { id?: string; hasElevatedPermission?: boolean }; + session?: { id?: string; hasElevatedPermission?: boolean; oauthBearerToken?: string | null }; user?: Omit< Partial, 'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt' From 5d7283e44d7742e52f79881abd4bb6ae2d2bbbca Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:43:27 +0200 Subject: [PATCH 014/163] fix: album update event emitting (#30120) --- server/src/services/asset-media.service.ts | 9 ++++++--- .../medium/specs/services/asset-media.service.spec.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 077bff3db2..818bd5eb91 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -351,9 +351,12 @@ export class AssetMediaService extends BaseService { } await this.albumRepository.addAssetIds(album.id, [assetId]); - for (const { user } of album.albumUsers) { - await this.eventRepository.emit('AlbumUpdate', { id: album.id, recipientId: user.id }); - } + const userIds = album.albumUsers.map(({ user }) => user.id); + await this.eventRepository.emit('AlbumUpdate', { + id: album.id, + userIds, + recipientIds: userIds, + }); } private requireQuota(auth: AuthDto, size: number) { diff --git a/server/test/medium/specs/services/asset-media.service.spec.ts b/server/test/medium/specs/services/asset-media.service.spec.ts index 2d3546d9b0..52ec8e12dc 100644 --- a/server/test/medium/specs/services/asset-media.service.spec.ts +++ b/server/test/medium/specs/services/asset-media.service.spec.ts @@ -216,7 +216,8 @@ describe(AssetService.name, () => { expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AlbumUpdate', { id: album.id, - recipientId: user.id, + userIds: [user.id], + recipientIds: [user.id], }); }); From a30ebab70ca48a97bcdeba3734ce86221eaa9337 Mon Sep 17 00:00:00 2001 From: Peter Ombodi Date: Tue, 21 Jul 2026 23:18:47 +0300 Subject: [PATCH 015/163] fix(mobile): refresh asset stack after deleting viewer item (#28164) * fix(mobile): refresh asset stack after deleting viewer item * fix(mobile): clamp stack index, filter visible stack assets * refactor(mobile): simplify stack asset refresh handling * fix(mobile): revert changes in DeleteLocalActionButton * style(mobile): reformat code * fix(mobile): remove redundant setStack --------- Co-authored-by: Peter Ombodi --- mobile/lib/domain/models/events.model.dart | 6 ++++ .../repositories/remote_asset.repository.dart | 8 ++++- .../delete_action_button.widget.dart | 14 ++++++-- .../asset_viewer/asset_page.widget.dart | 3 +- .../asset_viewer/asset_stack.provider.dart | 4 +++ .../asset_viewer/asset_viewer.page.dart | 35 +++++++++++++++++++ 6 files changed, 65 insertions(+), 5 deletions(-) diff --git a/mobile/lib/domain/models/events.model.dart b/mobile/lib/domain/models/events.model.dart index 9bbe00852e..ef034a0d03 100644 --- a/mobile/lib/domain/models/events.model.dart +++ b/mobile/lib/domain/models/events.model.dart @@ -24,6 +24,12 @@ class ViewerReloadAssetEvent extends Event { const ViewerReloadAssetEvent(); } +class ViewerStackAssetDeletedEvent extends Event { + final int stackIndex; + + const ViewerStackAssetDeletedEvent({required this.stackIndex}); +} + // Multi-Select Events class MultiSelectToggleEvent extends Event { final bool isEnabled; diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index f48d88d764..b2cecaca35 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -71,7 +71,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } final query = _db.remoteAssetEntity.select() - ..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not()) + ..where( + (row) => + row.stackId.equals(stackId) & + row.id.equals(asset.id).not() & + row.deletedAt.isNull() & + row.visibility.equalsValue(AssetVisibility.timeline), + ) ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]); return query.map((row) => row.toDto()).get(); diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart index 92747c6d44..2a7c4ba9d7 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_action_button.widget.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.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/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -59,13 +61,19 @@ class DeleteActionButton extends ConsumerWidget { } } - if (source == ActionSource.viewer) { - EventStream.shared.emit(const ViewerReloadAssetEvent()); - } + final currentAsset = ref.read(assetViewerProvider).currentAsset; + final stackIndex = ref.read(assetViewerProvider).stackIndex; final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); ref.read(multiSelectProvider.notifier).reset(); + if (source == ActionSource.viewer && result.success) { + final shouldRefreshStack = currentAsset is RemoteAsset && currentAsset.stackId != null; + EventStream.shared.emit( + shouldRefreshStack ? ViewerStackAssetDeletedEvent(stackIndex: stackIndex) : const ViewerReloadAssetEvent(), + ); + } + final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()}); if (context.mounted) { 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 65b9b3c686..1ef22a891f 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -413,7 +413,8 @@ class _AssetPageState extends ConsumerState { final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash)); final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null; if (stackChildren != null && stackChildren.isNotEmpty) { - displayAsset = stackChildren.elementAt(stackIndex); + final safeStackIndex = stackIndex.clamp(0, stackChildren.length - 1); + displayAsset = stackChildren.elementAt(safeStackIndex); } final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart index dae6db568c..c4e06e5ad9 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart @@ -11,6 +11,10 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier stack) { + state = AsyncData(stack); + } } final stackChildrenNotifier = AsyncNotifierProvider.autoDispose diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 25f99e2361..1065ebe25e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -221,6 +222,8 @@ class _AssetViewerState extends ConsumerState { _onTimelineReloadEvent(); case ViewerReloadAssetEvent(): _onViewerReloadEvent(); + case ViewerStackAssetDeletedEvent event: + _onViewerStackAssetDeletedEvent(event); default: } } @@ -236,6 +239,33 @@ class _AssetViewerState extends ConsumerState { _onAssetChanged(target); } + Future _onViewerStackAssetDeletedEvent(ViewerStackAssetDeletedEvent event) async { + final timelineAsset = ref.read(timelineServiceProvider).getAssetSafe(_currentPage); + if (timelineAsset == null) { + _onViewerReloadEvent(); + return; + } + + final stackProvider = stackChildrenNotifier(timelineAsset); + + ref.invalidate(stackProvider); + final stack = await ref.read(stackProvider.future); + + if (!mounted) { + return; + } + + if (stack.isEmpty) { + _onViewerReloadEvent(); + return; + } + + final targetIndex = math.min(event.stackIndex, stack.length - 1); + ref.read(assetViewerProvider.notifier) + ..setAsset(stack[targetIndex]) + ..setStackIndex(targetIndex); + } + void _onTimelineReloadEvent() { final timelineService = ref.read(timelineServiceProvider); final totalAssets = timelineService.totalAssets; @@ -252,6 +282,11 @@ class _AssetViewerState extends ConsumerState { if (index != _currentPage) { _pageController.jumpToPage(index); _onAssetChanged(index); + } else if (currentAsset is RemoteAsset && currentAsset.stackId != null && assetIndex == null) { + final timelineAsset = timelineService.getAssetSafe(index); + if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) { + _onAssetChanged(index); + } } else if (currentAsset != null && assetIndex == null) { _onAssetChanged(index); } From e508781125d52d8a102358ccb595481079461ec2 Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 21 Jul 2026 22:23:04 +0200 Subject: [PATCH 016/163] fix: limit ocr overlay to images (#30116) --- .../presentation/widgets/asset_viewer/bottom_bar.widget.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 01a48e7e97..53a7aee700 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -101,7 +101,7 @@ class ViewerBottomBar extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - OcrToggleButton(asset: asset), + if (asset.isImage) OcrToggleButton(asset: asset), if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), if (!isReadonlyModeEnabled) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), From 3485dbffdae5221fe255f60e461280944c48c024 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:53:50 +0530 Subject: [PATCH 017/163] refactor(mobile): extract CachedKeyValueRepository (#28789) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/models/settings_key.dart | 187 ++---------------- mobile/lib/domain/models/value_codec.dart | 153 ++++++++++++++ .../cached_key_value_repository.dart | 41 ++++ .../repositories/settings.repository.dart | 56 +++--- 4 files changed, 236 insertions(+), 201 deletions(-) create mode 100644 mobile/lib/domain/models/value_codec.dart create mode 100644 mobile/lib/infrastructure/repositories/cached_key_value_repository.dart diff --git a/mobile/lib/domain/models/settings_key.dart b/mobile/lib/domain/models/settings_key.dart index 00c5286e07..4308d69555 100644 --- a/mobile/lib/domain/models/settings_key.dart +++ b/mobile/lib/domain/models/settings_key.dart @@ -1,17 +1,16 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/value_codec.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/utils/semver.dart'; enum SettingsKey { // Theme - themePrimaryColor(codec: _EnumCodec(ImmichColorPreset.values)), - themeMode(codec: _EnumCodec(ThemeMode.values)), + themePrimaryColor(codec: EnumCodec(ImmichColorPreset.values)), + themeMode(codec: EnumCodec(ThemeMode.values)), themeDynamic(), themeColorfulInterface(), @@ -27,13 +26,13 @@ enum SettingsKey { // Network networkAutoEndpointSwitching(), + networkExternalEndpointList>(codec: ListCodec(PrimitiveCodec.string)), + networkCustomHeaders>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)), networkPreferredWifiName(), networkLocalEndpoint(), - networkExternalEndpointList>(codec: _ListCodec(_PrimitiveCodec.string)), - networkCustomHeaders>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)), // Album - albumSortMode(codec: _EnumCodec(AlbumSortMode.values)), + albumSortMode(codec: EnumCodec(AlbumSortMode.values)), albumIsReverse(), albumIsGrid(), @@ -47,195 +46,45 @@ enum SettingsKey { // Timeline timelineTilesPerRow(), - timelineGroupAssetsBy(codec: _EnumCodec(GroupAssetsBy.values)), + timelineGroupAssetsBy(codec: EnumCodec(GroupAssetsBy.values)), timelineStorageIndicator(), // Log - logLevel(codec: _EnumCodec(LogLevel.values)), + logLevel(codec: EnumCodec(LogLevel.values)), // Map mapShowFavoriteOnly(), mapRelativeDate(), mapIncludeArchived(), - mapThemeMode(codec: _EnumCodec(ThemeMode.values)), + mapThemeMode(codec: EnumCodec(ThemeMode.values)), mapWithPartners(), // Cleanup cleanupKeepFavorites(), - cleanupKeepMediaType(codec: _EnumCodec(AssetKeepType.values)), - cleanupKeepAlbumIds>(codec: _ListCodec(_PrimitiveCodec.string)), + cleanupKeepMediaType(codec: EnumCodec(AssetKeepType.values)), + cleanupKeepAlbumIds>(codec: ListCodec(PrimitiveCodec.string)), cleanupCutoffDaysAgo(), cleanupDefaultsInitialized(), // Share - shareFileType(codec: _EnumCodec(ShareAssetType.values)), + shareFileType(codec: EnumCodec(ShareAssetType.values)), // Slideshow slideshowRepeat(), slideshowDuration(), - slideshowLook(codec: _EnumCodec(SlideshowLook.values)), - slideshowDirection(codec: _EnumCodec(SlideshowDirection.values)), + slideshowLook(codec: EnumCodec(SlideshowLook.values)), + slideshowDirection(codec: EnumCodec(SlideshowDirection.values)), // Feature message - featureMessageSeenRelease(codec: _SemVerCodec()); + featureMessageSeenRelease(codec: SemVerCodec()); - final _SettingsCodec? _codecOverride; + final ValueCodec? _codecOverride; - const SettingsKey({_SettingsCodec? codec}) : _codecOverride = codec; + const SettingsKey({ValueCodec? codec}) : _codecOverride = codec; - _SettingsCodec get _codec => _codecOverride ?? _SettingsCodec.forType(T); + ValueCodec get _codec => _codecOverride ?? ValueCodec.forType(T); String encode(T value) => _codec.encode(value); T decode(String raw) => _codec.decode(raw); } - -sealed class _SettingsCodec { - const _SettingsCodec(); - - String encode(T value); - T decode(String raw); - - static final Map> _primitives = { - ..._register(_PrimitiveCodec.integer), - ..._register(_PrimitiveCodec.real), - ..._register(_PrimitiveCodec.boolean), - ..._register(_PrimitiveCodec.string), - ..._register(const _DateTimeCodec()), - }; - - static Map> _register(_SettingsCodec codec) => { - T: codec, - // Reifies the nullable type T so it can be used as a key in the _primitives map - _typeOf(): codec, - }; - - static Type _typeOf() => T; - - static _SettingsCodec forType(Type runtimeType) { - final codec = _primitives[runtimeType]; - if (codec == null) { - throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.'); - } - return codec as _SettingsCodec; - } -} - -final class _EnumCodec extends _SettingsCodec { - final List values; - - const _EnumCodec(this.values); - - @override - String encode(T value) => value.name; - - @override - T decode(String raw) => values.firstWhere((v) => v.name == raw); -} - -final class _DateTimeCodec extends _SettingsCodec { - const _DateTimeCodec(); - - @override - String encode(DateTime value) => value.toIso8601String(); - - @override - DateTime decode(String raw) => DateTime.parse(raw); -} - -final class _SemVerCodec extends _SettingsCodec { - const _SemVerCodec(); - - @override - String encode(SemVer value) => value.toString(); - - @override - SemVer decode(String raw) => SemVer.fromString(raw); -} - -final class _MapCodec extends _SettingsCodec> { - final _SettingsCodec _keyCodec; - final _SettingsCodec _valueCodec; - - const _MapCodec(this._keyCodec, this._valueCodec); - - @override - String encode(Map value) { - final entries = {}; - value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v)); - return jsonEncode(entries); - } - - @override - Map decode(String raw) { - try { - final decoded = jsonDecode(raw); - if (decoded is! Map) { - return {}; - } - final result = {}; - for (final entry in decoded.entries) { - final rawKey = entry.key; - final rawValue = entry.value; - if (rawKey is! String || rawValue is! String) { - return {}; - } - final k = _keyCodec.decode(rawKey); - final v = _valueCodec.decode(rawValue); - result[k] = v; - } - return result; - } on FormatException { - return {}; - } - } -} - -final class _ListCodec extends _SettingsCodec> { - final _SettingsCodec _elementCodec; - - const _ListCodec(this._elementCodec); - - @override - String encode(List value) => jsonEncode(value.map(_elementCodec.encode).toList()); - - @override - List decode(String raw) { - try { - final decoded = jsonDecode(raw); - if (decoded is! List) { - return []; - } - final result = []; - for (final item in decoded) { - if (item is! String) { - return []; - } - final element = _elementCodec.decode(item); - result.add(element); - } - return result; - } on FormatException { - return []; - } - } -} - -final class _PrimitiveCodec extends _SettingsCodec { - final T Function(String) _parse; - - const _PrimitiveCodec._(this._parse); - - @override - String encode(T value) => value.toString(); - - @override - T decode(String raw) => _parse(raw); - - static const integer = _PrimitiveCodec._(int.parse); - static const real = _PrimitiveCodec._(double.parse); - static const boolean = _PrimitiveCodec._(bool.parse); - static const string = _PrimitiveCodec._(_identity); - - static String _identity(String s) => s; -} diff --git a/mobile/lib/domain/models/value_codec.dart b/mobile/lib/domain/models/value_codec.dart new file mode 100644 index 0000000000..814ef1b926 --- /dev/null +++ b/mobile/lib/domain/models/value_codec.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; + +import 'package:immich_mobile/utils/semver.dart'; + +sealed class ValueCodec { + const ValueCodec(); + + String encode(T value); + T decode(String raw); + + static final Map> _primitives = { + ..._register(PrimitiveCodec.integer), + ..._register(PrimitiveCodec.real), + ..._register(PrimitiveCodec.boolean), + ..._register(PrimitiveCodec.string), + ..._register(const DateTimeCodec()), + }; + + static Map> _register(ValueCodec codec) => { + T: codec, + // Reifies the nullable type T so it can be used as a key in the _primitives map + _typeOf(): codec, + }; + + static Type _typeOf() => T; + + static ValueCodec forType(Type runtimeType) { + final codec = _primitives[runtimeType]; + if (codec == null) { + throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.'); + } + return codec as ValueCodec; + } +} + +final class EnumCodec extends ValueCodec { + final List values; + + const EnumCodec(this.values); + + @override + String encode(T value) => value.name; + + @override + T decode(String raw) => values.firstWhere((v) => v.name == raw); +} + +final class DateTimeCodec extends ValueCodec { + const DateTimeCodec(); + + @override + String encode(DateTime value) => value.toIso8601String(); + + @override + DateTime decode(String raw) => DateTime.parse(raw); +} + +final class SemVerCodec extends ValueCodec { + const SemVerCodec(); + + @override + String encode(SemVer value) => value.toString(); + + @override + SemVer decode(String raw) => SemVer.fromString(raw); +} + +final class MapCodec extends ValueCodec> { + final ValueCodec _keyCodec; + final ValueCodec _valueCodec; + + const MapCodec(this._keyCodec, this._valueCodec); + + @override + String encode(Map value) { + final entries = {}; + value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v)); + return jsonEncode(entries); + } + + @override + Map decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return {}; + } + final result = {}; + for (final entry in decoded.entries) { + final rawKey = entry.key; + final rawValue = entry.value; + if (rawKey is! String || rawValue is! String) { + continue; + } + final k = _keyCodec.decode(rawKey); + final v = _valueCodec.decode(rawValue); + result[k] = v; + } + return result; + } on FormatException { + return {}; + } + } +} + +final class ListCodec extends ValueCodec> { + final ValueCodec _elementCodec; + + const ListCodec(this._elementCodec); + + @override + String encode(List value) => jsonEncode(value.map(_elementCodec.encode).toList()); + + @override + List decode(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return []; + } + final result = []; + for (final item in decoded) { + if (item is! String) { + return []; + } + final element = _elementCodec.decode(item); + result.add(element); + } + return result; + } on FormatException { + return []; + } + } +} + +final class PrimitiveCodec extends ValueCodec { + final T Function(String) _parse; + + const PrimitiveCodec._(this._parse); + + @override + String encode(T value) => value.toString(); + + @override + T decode(String raw) => _parse(raw); + + static const integer = PrimitiveCodec._(int.parse); + static const real = PrimitiveCodec._(double.parse); + static const boolean = PrimitiveCodec._(bool.parse); + static const string = PrimitiveCodec._(_identity); + + static String _identity(String s) => s; +} diff --git a/mobile/lib/infrastructure/repositories/cached_key_value_repository.dart b/mobile/lib/infrastructure/repositories/cached_key_value_repository.dart new file mode 100644 index 0000000000..afeb31fa27 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/cached_key_value_repository.dart @@ -0,0 +1,41 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +// ignore: depend_on_referenced_packages +import 'package:meta/meta.dart'; + +abstract class CachedKeyValueRepository { + CachedKeyValueRepository(this._snapshot); + + S _snapshot; + S get snapshot => _snapshot; + @protected + set snapshot(S value) => _snapshot = value; + + List get keys; + + Object decodeValue(K key, String raw); + + S buildSnapshot(Map overrides); + + Selectable<({String key, String? value})> selectable(); + + Future refresh() async => _snapshot = _build(await selectable().get()); + + Stream watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows)); + + S _build(List<({String key, String? value})> rows) => buildSnapshot( + rows.fold({}, (overrides, row) { + final key = keys.firstWhereOrNull((key) => key.name == row.key); + if (key == null) { + return overrides; + } + + Object? decodedValue; + if (row.value != null) { + decodedValue = decodeValue(key, row.value!); + } + + return {...overrides, key: decodedValue}; + }), + ); +} diff --git a/mobile/lib/infrastructure/repositories/settings.repository.dart b/mobile/lib/infrastructure/repositories/settings.repository.dart index c974963f6a..7063779336 100644 --- a/mobile/lib/infrastructure/repositories/settings.repository.dart +++ b/mobile/lib/infrastructure/repositories/settings.repository.dart @@ -1,13 +1,14 @@ -import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/config/app_config.dart'; import 'package:immich_mobile/domain/models/settings_key.dart'; import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -class SettingsRepository extends DriftDatabaseRepository { +class SettingsRepository extends CachedKeyValueRepository { final Drift _db; - SettingsRepository._(this._db) : super(_db); + SettingsRepository._(this._db) : super(const .new()); static SettingsRepository? _instance; @@ -19,9 +20,6 @@ class SettingsRepository extends DriftDatabaseRepository { return instance; } - AppConfig _appConfig = const .new(); - AppConfig get appConfig => _appConfig; - static Future ensureInitialized(Drift db) async { if (_instance == null) { final instance = SettingsRepository._(db); @@ -31,7 +29,20 @@ class SettingsRepository extends DriftDatabaseRepository { return _instance!; } - Future refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get()); + @override + List get keys => SettingsKey.values; + + @override + Object decodeValue(SettingsKey key, String raw) => key.decode(raw); + + @override + AppConfig buildSnapshot(Map overrides) => AppConfig.fromEntries(overrides); + + @override + Selectable<({String key, String? value})> selectable() => + _db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value)); + + AppConfig get appConfig => snapshot; Future clear(Iterable keys) async { if (keys.isEmpty) { @@ -41,13 +52,15 @@ class SettingsRepository extends DriftDatabaseRepository { final names = keys.map((key) => key.name).toList(); await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go(); + var config = snapshot; for (final key in keys) { - _appConfig = _appConfig.write(key, defaultConfig.read(key)); + config = config.write(key, defaultConfig.read(key)); } + snapshot = config; } Future write(SettingsKey key, U value) async { - if (value == _appConfig.read(key)) { + if (value == snapshot.read(key)) { return; } @@ -65,29 +78,8 @@ class SettingsRepository extends DriftDatabaseRepository { .insertOnConflictUpdate( SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())), ); - _appConfig = _appConfig.write(key, value); + snapshot = snapshot.write(key, value); } - Stream watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) { - _applyOverrides(rows); - return _appConfig; - }); - - void _applyOverrides(List rows) { - _appConfig = AppConfig.fromEntries( - rows.fold({}, (overrides, row) { - final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key); - if (metadataKey == null) { - return overrides; - } - - Object? decodedValue; - if (row.value != null) { - decodedValue = metadataKey.decode(row.value!); - } - - return {...overrides, metadataKey: decodedValue}; - }), - ); - } + Stream watchConfig() => watchSnapshot(); } From 49c0baf07692e20ef6564237bd8c5e2751928d82 Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 21 Jul 2026 22:29:26 +0200 Subject: [PATCH 018/163] fix: disable slideshow crossfade on reduced motion (#29826) * fix: disable slideshow crossfade on reduced motion * chore: share photoview instances * chore: listen to changes --- .../pages/drift_slideshow.page.dart | 127 +++++++++++++++--- 1 file changed, 105 insertions(+), 22 deletions(-) diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 596b6fdf36..260ed3ba78 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -51,6 +51,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si int? _crossfadeFromIndex; int? _crossfadeToIndex; int _zoomCycle = 0; + bool _disableAnimations = false; @override initState() { @@ -70,6 +71,12 @@ class _DriftSlideshowPageState extends ConsumerState with Si unawaited(WakelockPlus.enable()); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _disableAnimations = MediaQuery.disableAnimationsOf(context); + } + @override dispose() { _timer.cancel(); @@ -166,6 +173,11 @@ class _DriftSlideshowPageState extends ConsumerState with Si } void _crossFadeToPage(int page) { + if (_disableAnimations) { + _pageController.jumpToPage(page); + return; + } + final previousIndex = _index; _pageController.jumpToPage(page); setState(() { @@ -273,19 +285,12 @@ class _DriftSlideshowPageState extends ConsumerState with Si } if (asset.isImage) { - final elapsed = _stopwatch.elapsedMilliseconds; - final duration = _config.duration * 1000; - - return TweenAnimationBuilder( + return _SlideshowProgressBar( key: Key(_index.toString()), - tween: Tween(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0), - duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), - builder: (context, value, _) => LinearProgressIndicator( - color: context.colorScheme.primary, - borderRadius: const BorderRadius.all(Radius.zero), - minHeight: 5, - value: value, - ), + durationMs: _config.duration * 1000, + elapsedMs: _stopwatch.elapsedMilliseconds, + paused: _paused, + color: context.colorScheme.primary, ); } else { return LinearProgressIndicator( @@ -334,6 +339,21 @@ class _DriftSlideshowPageState extends ConsumerState with Si final imageProvider = getFullImageProvider(asset, size: context.sizeData); if (asset.isImage) { + PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView( + imageProvider: imageProvider, + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: initialScale, + controller: PhotoViewController(), + onTapUp: (_, _, _) => _onTapUp(), + ); + + if (_disableAnimations) { + return buildPhotoView(scale); + } + final zoomOut = _zoomCycle.isOdd; final elapsed = _stopwatch.elapsedMilliseconds; final duration = _config.duration * 1000; @@ -349,16 +369,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si : 1.0, ), duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), - builder: (context, value, _) => PhotoView( - imageProvider: imageProvider, - index: index, - disableScaleGestures: true, - gaplessPlayback: true, - filterQuality: FilterQuality.high, - initialScale: scale * (1.0 + value * _kenBurnsZoom), - controller: PhotoViewController(), - onTapUp: (_, _, _) => _onTapUp(), - ), + builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), ); } else { final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status)); @@ -463,3 +474,75 @@ class _DriftSlideshowPageState extends ConsumerState with Si ); } } + +/// Progress bar for image slides, driven by an explicit [AnimationController]. +/// +/// [TweenAnimationBuilder] creates its controller internally with the default +/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system +/// "reduce motion" setting is on (flutter/flutter#164287). This owns its +/// controller so it can use [AnimationBehavior.preserve] and animate at the real +/// slide duration regardless of that setting. +class _SlideshowProgressBar extends StatefulWidget { + final int durationMs; + final int elapsedMs; + final bool paused; + final Color color; + + const _SlideshowProgressBar({ + super.key, + required this.durationMs, + required this.elapsedMs, + required this.paused, + required this.color, + }); + + @override + State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState(); +} + +class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: widget.durationMs), + animationBehavior: AnimationBehavior.preserve, + )..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0); + if (!widget.paused) { + _controller.forward(); + } + } + + @override + void didUpdateWidget(_SlideshowProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.durationMs != oldWidget.durationMs) { + _controller.duration = Duration(milliseconds: widget.durationMs); + } + if (widget.paused != oldWidget.paused) { + widget.paused ? _controller.stop() : _controller.forward(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) => LinearProgressIndicator( + color: widget.color, + borderRadius: const BorderRadius.all(Radius.zero), + minHeight: 5, + value: _controller.value, + ), + ); + } +} From a560e08693ca1287d1aff0af92b4a87af2fdf9d8 Mon Sep 17 00:00:00 2001 From: Imperator Ruscal <3578932+ImperatorRuscal@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:21:19 -0500 Subject: [PATCH 019/163] fix: re-evaluate OIDC role claim on every login and support array values (#29991) * fix(server): re-evaluate OIDC role claim on every login and support array values Previously the OIDC role claim (immich_role) was only read at user auto-registration time and only accepted as a scalar string, so admin status never updated after the first login and array-valued role/group claims (common with Keycloak, Entra ID, etc.) were silently ignored. Now the role claim is normalized from either a string or an array of strings, and existing users have their isAdmin flag synced from the claim on every login, keeping the IdP as the source of truth for privileges while leaving isAdmin untouched when the claim is blank. * fix(server): use .includes() instead of .some() for role claim check Satisfies unicorn/prefer-includes lint rule flagged by CI. * fix(server): resolve missing role claim to standard user and fix test mock Default the OIDC role claim to 'user' when the IdP omits it so auto-registration doesn't crash, and add the missing getAdmin mock in the auth.service test so it correctly simulates an existing admin rather than the first-user-registration path. * fix: claim sync logic --------- Co-authored-by: Jason Rasmussen --- server/src/services/auth.service.spec.ts | 119 +++++++++++++++++++++++ server/src/services/auth.service.ts | 27 +++-- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 5c76b38542..dad13ef5f5 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1140,6 +1140,125 @@ describe(AuthService.name, () => { expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true })); }); + + it('should create an admin user if the role claim is an array containing admin', async () => { + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ immich_role: ['user', 'admin'] }), + }); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getByOAuthId.mockResolvedValue(void 0); + mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' })); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true })); + }); + + it('should create a standard user if the role claim is an array containing only user', async () => { + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ immich_role: ['user'] }), + }); + mocks.user.getByEmail.mockResolvedValue(void 0); + mocks.user.getByOAuthId.mockResolvedValue(void 0); + mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true })); + mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' })); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false })); + }); + + it('should promote an existing user to admin if the role claim contains admin on login', async () => { + const user = UserFactory.create({ isAdmin: false, oauthId: 'oauth-id' }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_role: 'admin' }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.user.update.mockResolvedValue({ ...user, isAdmin: true }); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true }); + }); + + it('should demote an existing admin if the role claim only contains user on login', async () => { + const user = UserFactory.create({ isAdmin: true, oauthId: 'oauth-id' }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId, immich_role: ['user'] }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.user.update.mockResolvedValue({ ...user, isAdmin: false }); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: false }); + }); + + it('should not change isAdmin for an existing user if the role claim is blank', async () => { + const user = UserFactory.create({ isAdmin: true, oauthId: 'oauth-id' }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ + profile: OAuthProfileFactory.create({ sub: user.oauthId }), + }); + mocks.user.getByOAuthId.mockResolvedValue(user); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, + {}, + loginDetails, + ); + + expect(mocks.user.update).not.toHaveBeenCalled(); + }); + + it('should re-evaluate the role claim for a user linked by email', async () => { + const user = UserFactory.create({ isAdmin: false }); + const profile = OAuthProfileFactory.create({ immich_role: 'admin' }); + + mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); + mocks.user.getByEmail.mockResolvedValue(user); + mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub }); + mocks.user.update.mockResolvedValueOnce({ ...user, oauthId: profile.sub, isAdmin: true }); + mocks.session.create.mockResolvedValue(SessionFactory.create()); + + await sut.callback( + { url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' }, + {}, + loginDetails, + ); + + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub }); + expect(mocks.user.update).toHaveBeenCalledWith(user.id, { isAdmin: true }); + }); }); describe('link', () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index d30c912194..f3be40f7dd 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -331,6 +331,13 @@ export class AuthService extends BaseService { } } + const role = this.getRoleClaim(profile, roleClaim); + const isAdmin = role === 'admin'; + + if (user && role && isAdmin !== user.isAdmin) { + user = await this.userRepository.update(user.id, { isAdmin }); + } + // register new user if (!user) { if (!autoRegister) { @@ -356,11 +363,6 @@ export class AuthService extends BaseService { default: defaultStorageQuota, isValid: (value: unknown) => Number(value) >= 0, }); - const role = this.getClaim<'admin' | 'user'>(profile, { - key: roleClaim, - default: 'user', - isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value), - }); user = await this.createUser({ name: @@ -372,7 +374,7 @@ export class AuthService extends BaseService { oauthId: profile.sub, quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB, storageLabel: storageLabel || null, - isAdmin: role === 'admin', + isAdmin, }); } @@ -641,6 +643,19 @@ export class AuthService extends BaseService { return options.isValid(value) ? (value as T) : options.default; } + private getRoleClaim(profile: OAuthProfile, roleClaim: string): 'admin' | 'user' | undefined { + const value = profile[roleClaim as keyof OAuthProfile]; + const roles = Array.isArray(value) ? value : [value]; + const isRole = (role: string) => roles.includes(role); + + if (isRole('admin')) { + return 'admin'; + } + if (isRole('user')) { + return 'user'; + } + } + private resolveRedirectUri( { mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean }, url: string, From c9539a4aa8814b2d05ab267386d90cc1842b1c9c Mon Sep 17 00:00:00 2001 From: Yaros Date: Tue, 21 Jul 2026 23:26:23 +0200 Subject: [PATCH 020/163] fix(mobile): map unresponsive after viewing asset (#27036) * fix(mobile): map unresponsive after viewing asset * chore: better fix --- mobile/lib/presentation/widgets/map/map.widget.dart | 9 ++++++--- mobile/lib/providers/routes.provider.dart | 1 + mobile/lib/routing/app_navigation_observer.dart | 11 +++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/mobile/lib/presentation/widgets/map/map.widget.dart b/mobile/lib/presentation/widgets/map/map.widget.dart index f6c4f7d468..a68a475ad4 100644 --- a/mobile/lib/presentation/widgets/map/map.widget.dart +++ b/mobile/lib/presentation/widgets/map/map.widget.dart @@ -16,7 +16,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map_utils.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; @@ -133,8 +132,7 @@ class _DriftMapState extends ConsumerState { // When the AssetViewer is open, the DriftMap route stays alive in the background. // If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed, // which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever"). - final currentRoute = ref.read(currentRouteNameProvider); - if (currentRoute == AssetViewerRoute.name) { + if (ref.read(isAssetViewerOpenProvider)) { return; } @@ -183,6 +181,11 @@ class _DriftMapState extends ConsumerState { @override Widget build(BuildContext context) { + ref.listen(isAssetViewerOpenProvider, (previous, current) { + if (previous == true && !current) { + _debouncer.run(() => setBounds(forceReload: true)); + } + }); return Stack( children: [ _Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady), diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index c51f67bc0e..a752a51a7d 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; final inLockedViewProvider = StateProvider((ref) => false); +final isAssetViewerOpenProvider = StateProvider((ref) => false); final currentRouteNameProvider = StateProvider((ref) => null); final previousRouteNameProvider = StateProvider((ref) => null); final previousRouteDataProvider = StateProvider((ref) => null); diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index b6b08d7831..09d17da182 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -24,9 +24,20 @@ class AppNavigationObserver extends AutoRouterObserver { ref.read(currentRouteNameProvider.notifier).state = route.settings.name; ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name; ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings; + if (route.settings.name == AssetViewerRoute.name) { + ref.read(isAssetViewerOpenProvider.notifier).state = true; + } }); } + @override + void didPop(Route route, Route? previousRoute) { + _handleDriftLockedFolderState(previousRoute ?? route, null); + if (route.settings.name == AssetViewerRoute.name) { + Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false); + } + } + _handleDriftLockedFolderState(Route route, Route? previousRoute) { final isInLockedView = ref.read(inLockedViewProvider); final isFromLockedViewToDetailView = From f413c446e38279086c2626eb812ad30688185e68 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:15:38 +0200 Subject: [PATCH 021/163] chore(deps): update github-actions (#30089) --- .github/workflows/build-mobile.yml | 4 ++-- .github/workflows/check-openapi.yml | 2 +- .github/workflows/close-duplicates.yml | 2 +- .github/workflows/codeql-analysis.yml | 6 +++--- .github/workflows/prepare-release.yml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 39a1a93077..1c74b34c55 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -106,7 +106,7 @@ jobs: working-directory: ./mobile run: printf "%s" $KEY_JKS | base64 -d > android/key.jks - - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 + - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: 'zulu' java-version: '17' @@ -241,7 +241,7 @@ jobs: run: flutter build ios --config-only --no-codesign - name: Setup Ruby - uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0 + uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0 with: ruby-version: '3.3' bundler-cache: true diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 85fb3ed38c..7df2bfd6ab 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5 + uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6 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 157ff001b4..cf932dac8d 100644 --- a/.github/workflows/close-duplicates.yml +++ b/.github/workflows/close-duplicates.yml @@ -39,7 +39,7 @@ jobs: needs: [get_body, should_run] if: ${{ needs.should_run.outputs.should_run == 'true' }} container: - image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62 + image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a outputs: checked: ${{ steps.get_checkbox.outputs.checked }} steps: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index e36bbda495..9bc8b913ca 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -71,7 +71,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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 # ℹ️ 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 @@ -84,6 +84,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index bd580b72ad..976ba9906a 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -152,7 +152,7 @@ jobs: github-token: ${{ steps.generate-token.outputs.token }} - name: Create draft release - uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: draft: true prerelease: ${{ needs.bump_version.outputs.rc }} From 998fd5f6a88c9db7ed864881e24120ab87218aa5 Mon Sep 17 00:00:00 2001 From: Ijas Date: Wed, 22 Jul 2026 13:49:19 +0530 Subject: [PATCH 022/163] fix(mobile): send date-only value for memories query param (#30049) * fix(ios-widget): send date-only value for memories query param Server v3.0.3 (immich-app#29907) validates the /memories `for` query param with a date-only schema (YYYY-MM-DD). The iOS widget still sent a full ISO 8601 datetime, so every request 400s and the widget shows "Unable to connect to your Immich instance" on any server >= v3.0.3. Build the value from the local calendar day, matching what the web client (memory-manager.svelte.ts) and Android already send, instead of a UTC datetime. Also fix a related bug in the timeline provider: fetchMemory was called with a bare try inside a Task, so a failure silently dropped the error and never called completion, leaving the widget in a stale state instead of showing an error card. Wrap it in a guard/try?, same pattern already used in getSnapshot. Fixes immich-app#30044 Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com> * address review: drop unrelated widget change and comment Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com> --------- Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com> Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com> Co-authored-by: Santo Shakil --- mobile/ios/WidgetExtension/ImmichAPI.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift index c5d7971aba..f5ee578dad 100644 --- a/mobile/ios/WidgetExtension/ImmichAPI.swift +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -203,7 +203,10 @@ class ImmichAPI { func fetchMemory(for date: Date) async throws -> [MemoryResult] { // get URL - let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())] + let localDay = date.formatted( + Date.ISO8601FormatStyle(timeZone: .current).year().month().day().dateSeparator(.dash) + ) + let memoryParams = [URLQueryItem(name: "for", value: localDay)] guard let searchURL = buildRequestURL( serverConfig: serverConfig, From 0f5bf6071fd908f25f6037f949a77403b68c07dc Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 22 Jul 2026 01:48:31 -0700 Subject: [PATCH 023/163] chore(server): test assetDateFilter workflow method (#30110) --- .../workflow/workflow-core-plugin.spec.ts | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index d8830e91e6..1ad40ecca7 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -433,9 +433,10 @@ describe('core plugin', () => { describe('assetDateFilter', () => { it('should favorite assets created during the first 7 days of a specific year and month', async () => { const { user } = await ctx.newUser(); - const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([ + const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([ ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-01') }), ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-07T23:59:59Z') }), + ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }), ]); const workflow = await createWorkflow({ @@ -461,6 +462,46 @@ describe('core plugin', () => { await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }); await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true }); + + await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id }); + await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false }); + }); + + it('should match recurring dates regardless of the year', async () => { + const { user } = await ctx.newUser(); + const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }] = await Promise.all([ + ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2026-03-01') }), + ctx.newAsset({ ownerId: user.id, localDateTime: new Date('1998-12-21') }), + ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2000-04-08T00:00:00Z') }), + ]); + await ctx.newAsset({ ownerId: user.id, localDateTime: new Date('2010-06-15') }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [ + { + method: 'immich-plugin-core#assetDateFilter', + config: { + startDate: { day: 12, month: 12, year: 2000 }, + endDate: { day: 30, month: 3, year: 2001 }, + recurring: true, + }, + }, + { + method: 'immich-plugin-core#assetFavorite', + }, + ], + }); + + await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset1.id }); + await expect(ctx.get(AssetRepository).getById(asset1.id)).resolves.toMatchObject({ isFavorite: true }); + + await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset2.id }); + await expect(ctx.get(AssetRepository).getById(asset2.id)).resolves.toMatchObject({ isFavorite: true }); + + await ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset3.id }); + await expect(ctx.get(AssetRepository).getById(asset3.id)).resolves.toMatchObject({ isFavorite: false }); }); }); From 99370b2ac452ffd5507d1fb675931e9af81260f7 Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 22 Jul 2026 10:54:26 +0200 Subject: [PATCH 024/163] fix: admin user details responsive layout (#30114) --- web/src/lib/components/CalendarHeatmap.svelte | 52 ++++++++++--------- .../routes/admin/users/[id]/+layout.svelte | 2 +- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/web/src/lib/components/CalendarHeatmap.svelte b/web/src/lib/components/CalendarHeatmap.svelte index 477e796243..f2882c63b7 100644 --- a/web/src/lib/components/CalendarHeatmap.svelte +++ b/web/src/lib/components/CalendarHeatmap.svelte @@ -55,34 +55,36 @@ {/each} --> -
-
- {#if Info.getStartOfWeek({ locale: $locale }) === 7} -
- {/if} -
{weekdays[0]}
-
{weekdays[1]}
-
{weekdays[2]}
- {#if Info.getStartOfWeek({ locale: $locale }) === 1} -
{weekdays[3]}
- {/if} -
+
+
+
+ {#if Info.getStartOfWeek({ locale: $locale }) === 7} +
+ {/if} +
{weekdays[0]}
+
{weekdays[1]}
+
{weekdays[2]}
+ {#if Info.getStartOfWeek({ locale: $locale }) === 1} +
{weekdays[3]}
+ {/if} +
- {#each data.series as day, idx (day.date)} - {@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString( - { month: 'short', day: 'numeric' }, - { locale: $locale }, - )} -
- {/each} + {#each data.series as day, idx (day.date)} + {@const date = DateTime.fromISO(day.date, { zone: 'utc' }).toLocaleString( + { month: 'short', day: 'numeric' }, + { locale: $locale }, + )} +
+ {/each} +
-
+
{$t('less')} diff --git a/web/src/routes/admin/users/[id]/+layout.svelte b/web/src/routes/admin/users/[id]/+layout.svelte index 8e5a8c90b0..dc49e5f421 100644 --- a/web/src/routes/admin/users/[id]/+layout.svelte +++ b/web/src/routes/admin/users/[id]/+layout.svelte @@ -213,7 +213,7 @@ -
+
{$t('uploads')} From ba9ec7c80ed097be26a5141cafdf8d55632104d0 Mon Sep 17 00:00:00 2001 From: Yaros Date: Wed, 22 Jul 2026 10:58:38 +0200 Subject: [PATCH 025/163] fix: backup delay translation key parsing (#30112) --- .../backup_settings/drift_backup_settings.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart index e5debb43fe..44a1a1d7a9 100644 --- a/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/drift_backup_settings.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; @@ -10,6 +9,7 @@ import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; @@ -255,11 +255,11 @@ class _BackupDelaySlider extends ConsumerWidget { _ => 600, }; - static String formatBackupDelaySliderValue(int v) => switch (v) { - 0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}), - 1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}), - 2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}), - _ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}), + static String formatBackupDelaySliderValue(BuildContext context, int v) => switch (v) { + 0 => context.t.setting_notifications_notify_seconds(count: 5), + 1 => context.t.setting_notifications_notify_seconds(count: 30), + 2 => context.t.setting_notifications_notify_minutes(count: 2), + _ => context.t.setting_notifications_notify_minutes(count: 10), }; @override @@ -272,8 +272,8 @@ class _BackupDelaySlider extends ConsumerWidget { Padding( padding: const EdgeInsets.only(left: 24.0, top: 8.0), child: Text( - 'backup_controller_page_background_delay'.tr( - namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)}, + context.t.backup_controller_page_background_delay( + duration: formatBackupDelaySliderValue(context, currentValue), ), style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), ), @@ -291,7 +291,7 @@ class _BackupDelaySlider extends ConsumerWidget { max: 3.0, min: 0.0, divisions: 3, - label: formatBackupDelaySliderValue(currentValue), + label: formatBackupDelaySliderValue(context, currentValue), ), ], ); From 75053ebfbaa79414387b9cd2a1f9319bdc8880fc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:59:04 +0200 Subject: [PATCH 026/163] chore(deps): update terraform cloudflare to v4.52.8 (#29671) --- .../docs-release/.terraform.lock.hcl | 58 +++++++++---------- .../modules/cloudflare/docs-release/config.tf | 2 +- .../cloudflare/docs/.terraform.lock.hcl | 58 +++++++++---------- deployment/modules/cloudflare/docs/config.tf | 2 +- 4 files changed, 58 insertions(+), 62 deletions(-) diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 7fdd96847c..049d3c0fd9 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,35 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.52.7" - constraints = "4.52.7" + version = "4.52.8" + constraints = "4.52.8" hashes = [ - "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", + "h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=", + "h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=", + "h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=", + "h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=", + "h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=", + "h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=", + "h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=", + "h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=", + "h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=", + "h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=", + "h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=", + "h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=", + "h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=", + "zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477", + "zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d", + "zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363", + "zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", - "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", - "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", - "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", - "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", - "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", - "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", - "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", - "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", + "zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f", + "zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0", + "zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b", + "zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30", + "zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6", + "zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91", + "zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29", + "zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb", + "zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 7c59cdd2e3..ea1a441a4b 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.7" + version = "4.52.8" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 7fdd96847c..049d3c0fd9 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,35 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.52.7" - constraints = "4.52.7" + version = "4.52.8" + constraints = "4.52.8" hashes = [ - "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", + "h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=", + "h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=", + "h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=", + "h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=", + "h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=", + "h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=", + "h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=", + "h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=", + "h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=", + "h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=", + "h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=", + "h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=", + "h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=", + "zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477", + "zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d", + "zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363", + "zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", - "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", - "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", - "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", - "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", - "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", - "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", - "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", - "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", + "zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f", + "zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0", + "zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b", + "zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30", + "zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6", + "zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91", + "zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29", + "zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb", + "zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 7c59cdd2e3..ea1a441a4b 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.7" + version = "4.52.8" } } } From 33cba2f9af4f7b2682d16b77d4fff8487977bcad Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:59:29 +0200 Subject: [PATCH 027/163] chore(deps): update grafana/grafana docker tag to v12.4.5-ubuntu (#29670) --- docker/docker-compose.prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index d30aa99e91..fdc0e7b1fb 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -97,7 +97,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58 + image: grafana/grafana:12.4.5-ubuntu@sha256:00396460e499415c828b7c298f19287c8a0f95e72412ee37ac11723655c2d6b9 volumes: - grafana-data:/var/lib/grafana From 2d3cf8ffaaff928a4edae2eabb1fd01953d21a47 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:16:09 +0200 Subject: [PATCH 028/163] chore(deps): update dependency terragrunt to v1.1.1 (#29399) --- deployment/mise.toml | 2 +- mise.lock | 44 ++++++++++++++++++++++---------------------- mise.toml | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/deployment/mise.toml b/deployment/mise.toml index fadc28dc2e..24012b155e 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,5 +1,5 @@ [tools] -terragrunt = "1.0.3" +terragrunt = "1.1.1" opentofu = "1.11.6" [tasks."tg:fmt"] diff --git a/mise.lock b/mise.lock index 0eabf0ca4e..049c0f31e8 100644 --- a/mise.lock +++ b/mise.lock @@ -327,40 +327,40 @@ url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981" provenance = "github-attestations" [[tools.terragrunt]] -version = "1.0.3" +version = "1.1.1" backend = "aqua:gruntwork-io/terragrunt" [tools.terragrunt."platforms.linux-arm64"] -checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" -url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" +checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2" +url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148" [tools.terragrunt."platforms.linux-arm64-musl"] -checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" -url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" +checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2" +url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148" [tools.terragrunt."platforms.linux-x64"] -checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" -url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" +checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53" +url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968" [tools.terragrunt."platforms.linux-x64-musl"] -checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" -url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" +checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53" +url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968" [tools.terragrunt."platforms.macos-arm64"] -checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa" -url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz" -url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084" +checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794" +url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628" [tools.terragrunt."platforms.macos-x64"] -checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b" -url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz" -url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970" +checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2" +url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520" [tools.terragrunt."platforms.windows-x64"] -checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db" -url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz" -url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412" +checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115" +url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421" diff --git a/mise.toml b/mise.toml index d3db6eb783..5d33d50376 100644 --- a/mise.toml +++ b/mise.toml @@ -17,7 +17,7 @@ config_roots = [ [tools] node = "24.15.0" pnpm = "11.13.1" -terragrunt = "1.0.3" +terragrunt = "1.1.1" opentofu = "1.11.6" "npm:oazapfts" = "7.5.0" "github:extism/cli" = "1.6.3" From 8bbc25270331dd789e0cd7749a80b3b4e7f8fc07 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 22 Jul 2026 02:20:57 -0700 Subject: [PATCH 029/163] feat: exif metadata workflow filter (#29295) --- packages/plugin-core/manifest.json | 70 ++++++++++++++++++++++++++ packages/plugin-core/src/index.ts | 80 ++++++++++++++++++------------ 2 files changed, 117 insertions(+), 33 deletions(-) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index d55ad43e70..a72d60c04d 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -308,6 +308,76 @@ }, "uiHints": ["Filter"] }, + { + "name": "assetExifFilter", + "title": "Filter by EXIF metadata", + "description": "Filter assets by their EXIF properties", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "property": { + "title": "Property", + "description": "EXIF property to match", + "type": "string", + "enum": [ + "make", + "model", + "exifImageWidth", + "exifImageHeight", + "fileSizeInByte", + "orientation", + "lensModel", + "fNumber", + "focalLength", + "iso", + "description", + "fps", + "exposureTime", + "livePhotoCID", + "timeZone", + "projectionType", + "profileDescription", + "colorspace", + "bitsPerSample", + "rating" + ], + "uiHint": { + "order": 1 + } + }, + "pattern": { + "type": "string", + "title": "Pattern", + "description": "Text or regex pattern to match against property value", + "uiHint": { + "order": 2 + } + }, + "matchType": { + "type": "string", + "title": "Match type", + "enum": ["contains", "startsWith", "exact", "regex"], + "default": "contains", + "description": "Type of pattern matching to perform", + "uiHint": { + "order": 3 + } + }, + "caseSensitive": { + "type": "boolean", + "default": false, + "title": "Case sensitive", + "description": "Whether matching should be case-sensitive", + "uiHint": { + "order": 4 + } + } + }, + "required": ["property", "pattern"] + }, + "uiHints": ["Filter"] + }, { "name": "assetArchive", "title": "Archive asset", diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index d412ad7708..e3f180f98b 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -2,6 +2,42 @@ import { wrapper } from '@immich/plugin-sdk'; import { AssetVisibility } from '@immich/sdk'; import type { Manifest } from '../dist/index.d.ts'; +type MatchValueConfig = { + pattern: string; + matchType?: 'contains' | 'exact' | 'regex' | 'startsWith'; + caseSensitive?: boolean; +}; + +const matchValueResult = (value: string, config: MatchValueConfig) => { + const { pattern, matchType = 'contains', caseSensitive = false } = config; + const searchName = caseSensitive ? value : value.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + + switch (matchType) { + case 'contains': { + return { workflow: { continue: searchName.includes(searchPattern) } }; + } + + case 'exact': { + return { workflow: { continue: searchName === searchPattern } }; + } + + case 'startsWith': { + return { workflow: { continue: searchName.startsWith(searchPattern) } }; + } + + case 'regex': { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + return { workflow: { continue: regex.test(value) } }; + } + + default: { + return {}; + } + } +}; + const methods = wrapper({ assetAddToAlbums: ({ config, data, functions }) => { const assetId = data.asset.id; @@ -53,39 +89,7 @@ const methods = wrapper({ } }, - assetFileFilter: ({ data, config }) => { - const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config; - - const { asset } = data; - - const fileName = usePath ? asset.originalPath : asset.originalFileName; - const searchName = caseSensitive ? fileName : fileName.toLowerCase(); - const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); - - switch (matchType) { - case 'contains': { - return { workflow: { continue: searchName.includes(searchPattern) } }; - } - - case 'exact': { - return { workflow: { continue: searchName === searchPattern } }; - } - - case 'startsWith': { - return { workflow: { continue: searchName.startsWith(searchPattern) } }; - } - - case 'regex': { - const flags = caseSensitive ? '' : 'i'; - const regex = new RegExp(searchPattern, flags); - return { workflow: { continue: regex.test(fileName) } }; - } - - default: { - return {}; - } - } - }, + assetFileFilter: ({ data, config }) => matchValueResult(data.asset.originalFileName || '', config), assetLocationFilter: ({ config, data }) => { if ( @@ -124,6 +128,14 @@ const methods = wrapper({ return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; }, + assetExifFilter: ({ config, data }) => { + if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) { + return { workflow: { continue: false } }; + } + + return matchValueResult(String(data.asset.exifInfo[config.property]), config); + }, + assetDateFilter: ({ config, data }) => { const assetDate = new Date(data.asset.localDateTime); let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day); @@ -200,6 +212,7 @@ const { assetFavorite, assetFileFilter, assetLocationFilter, + assetExifFilter, assetDateFilter, assetLock, assetMissingTimeZoneFilter, @@ -217,6 +230,7 @@ export { assetFavorite, assetFileFilter, assetLocationFilter, + assetExifFilter, assetDateFilter, assetLock, assetMissingTimeZoneFilter, From 8435d219e4206dca41ad648c13cadd511c0c5621 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:30:08 +0000 Subject: [PATCH 030/163] chore(deps): update dependency opentofu to v1.12.4 (#28491) --- deployment/mise.toml | 2 +- mise.lock | 44 ++++++++++++++++++++++---------------------- mise.toml | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/deployment/mise.toml b/deployment/mise.toml index 24012b155e..2e26da09f6 100644 --- a/deployment/mise.toml +++ b/deployment/mise.toml @@ -1,6 +1,6 @@ [tools] terragrunt = "1.1.1" -opentofu = "1.11.6" +opentofu = "1.12.4" [tasks."tg:fmt"] run = "terragrunt hclfmt" diff --git a/mise.lock b/mise.lock index 049c0f31e8..bab6b6902d 100644 --- a/mise.lock +++ b/mise.lock @@ -248,43 +248,43 @@ version = "7.5.0" backend = "npm:oazapfts" [[tools.opentofu]] -version = "1.11.6" +version = "1.12.4" backend = "aqua:opentofu/opentofu" [tools.opentofu."platforms.linux-arm64"] -checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" -url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" +checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678" [tools.opentofu."platforms.linux-arm64-musl"] -checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" -url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" -url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" +checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678" [tools.opentofu."platforms.linux-x64"] -checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" -url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" +checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677" [tools.opentofu."platforms.linux-x64-musl"] -checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" -url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" -url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" +checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677" [tools.opentofu."platforms.macos-arm64"] -checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1" -url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz" -url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399" +checksum = "sha256:7c06e4390d9ccd467773e37ff1c3d833c7ca0c24742cd9e9ad47284bea472247" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646544" [tools.opentofu."platforms.macos-x64"] -checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6" -url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz" -url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384" +checksum = "sha256:ead1d2ce643addb4ffeb93240b9377ae1c2fd793a6bd22d65922ac37adfdf546" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646688" [tools.opentofu."platforms.windows-x64"] -checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e" -url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz" -url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406" +checksum = "sha256:a4d86a07755c8d151f20f945e6cfb5b40deeed942af36a9bd385c5c2e965d5dd" +url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_windows_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646543" [[tools.pnpm]] version = "11.13.1" diff --git a/mise.toml b/mise.toml index 5d33d50376..3e45d24223 100644 --- a/mise.toml +++ b/mise.toml @@ -18,7 +18,7 @@ config_roots = [ node = "24.15.0" pnpm = "11.13.1" terragrunt = "1.1.1" -opentofu = "1.11.6" +opentofu = "1.12.4" "npm:oazapfts" = "7.5.0" "github:extism/cli" = "1.6.3" "github:webassembly/binaryen" = "version_124" From 8a875f59787a90817ef4300a79d64cc49d6cbf3f Mon Sep 17 00:00:00 2001 From: okxint <130782884+okxint@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:23:45 +0530 Subject: [PATCH 031/163] fix(web): use correct date field for shift-click range in Recently Added (#30071) --- .../internal/search-support.svelte.ts | 13 ++++-- .../timeline-manager.svelte.spec.ts | 42 ++++++++++++++++++- .../timeline-manager.svelte.ts | 4 ++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts index 8a73bcce7d..d6104f4051 100644 --- a/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts +++ b/web/src/lib/managers/timeline-manager/internal/search-support.svelte.ts @@ -1,6 +1,6 @@ -import { AssetOrder, type AssetResponseDto } from '@immich/sdk'; +import { AssetOrder, AssetOrderBy, type AssetResponseDto } from '@immich/sdk'; import { DateTime } from 'luxon'; -import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; +import { getOrderingDate, plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineMonth } from '../timeline-month.svelte'; import type { AssetDescriptor, Direction, TimelineAsset } from '../types'; @@ -125,7 +125,14 @@ export async function retrieveRange(timelineManager: TimelineManager, start: Ass return []; } const assetOrder: AssetOrder = timelineManager.getAssetOrder(); - if (plainDateTimeCompare(assetOrder === AssetOrder.Desc, startAsset.localDateTime, endAsset.localDateTime) < 0) { + const orderBy: AssetOrderBy = timelineManager.getOrderBy(); + if ( + plainDateTimeCompare( + assetOrder === AssetOrder.Desc, + getOrderingDate(startAsset, orderBy), + getOrderingDate(endAsset, orderBy), + ) < 0 + ) { [startAsset, endAsset] = [endAsset, startAsset]; // eslint-disable-next-line no-useless-assignment [startTimelineMonth, endTimelineMonth] = [endTimelineMonth, startTimelineMonth]; diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts index 1762553897..f44f8a3046 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.spec.ts @@ -1,4 +1,4 @@ -import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; +import { AssetOrderBy, AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { tick } from 'svelte'; import { sdkMock } from '$lib/__mocks__/sdk.mock'; import { eventManager } from '$lib/managers/event-manager.svelte'; @@ -808,4 +808,44 @@ describe('TimelineManager', () => { expect(b.showAssetOwners).toBe(true); }); }); + + describe('retrieveRange', () => { + it('uses createdAt ordering in the Recently Added view (orderBy=CreatedAt)', async () => { + // Simulate the "Recently Added" bug: two assets whose localDateTime order is + // the reverse of their createdAt (upload) order. Before the fix, retrieveRange + // compared localDateTime and selected the wrong range direction. + const timelineManager = new TimelineManager(); + sdkMock.getTimeBuckets.mockResolvedValue([]); + await timelineManager.updateOptions({ orderBy: AssetOrderBy.CreatedAt }); + + // assetA was taken recently (2024) but uploaded first (2025-01) + const assetA = timelineAssetFactory.build({ + localDateTime: fromISODateTimeUTCToObject('2024-06-01T00:00:00.000Z'), + createdAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'), + fileCreatedAt: fromISODateTimeUTCToObject('2025-01-20T00:00:00.000Z'), + }); + // assetB was taken long ago (2018) but uploaded second (2025-02) + const assetB = timelineAssetFactory.build({ + localDateTime: fromISODateTimeUTCToObject('2018-03-15T00:00:00.000Z'), + createdAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'), + fileCreatedAt: fromISODateTimeUTCToObject('2025-02-10T00:00:00.000Z'), + }); + // assetC is an unrelated asset that should not appear in the selection + const assetC = timelineAssetFactory.build({ + localDateTime: fromISODateTimeUTCToObject('2022-01-01T00:00:00.000Z'), + createdAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'), + fileCreatedAt: fromISODateTimeUTCToObject('2025-03-01T00:00:00.000Z'), + }); + + timelineManager.upsertAssets([assetA, assetB, assetC]); + + // Shift-click from assetB (uploaded most recently) to assetA — the range + // between them in the "Recently Added" timeline should contain only those two. + const range = await timelineManager.retrieveRange({ id: assetB.id }, { id: assetA.id }); + const ids = range.map((a) => a.id); + expect(ids).toContain(assetA.id); + expect(ids).toContain(assetB.id); + expect(ids).not.toContain(assetC.id); + }); + }); }); diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 0b9c6d4b07..3f8277fbc4 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -625,6 +625,10 @@ export class TimelineManager extends VirtualScrollManager { return this.#options.order ?? AssetOrder.Desc; } + getOrderBy() { + return this.#options.orderBy ?? AssetOrderBy.TakenAt; + } + protected postCreateSegments(): void { this.months.sort((a, b) => { return a.yearMonth.year === b.yearMonth.year From 20997823d8c234ce514aaaead72bdf85126627fb Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:55:47 +0200 Subject: [PATCH 032/163] chore: mise monorepo support is now stable (#30131) --- mise.lock | 44 ++++++++++++++++++++++---------------------- mise.toml | 3 +-- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/mise.lock b/mise.lock index bab6b6902d..a400a1e5c7 100644 --- a/mise.lock +++ b/mise.lock @@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" +[tools."github:jellyfin/jellyfin-ffmpeg".options] +asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" + +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] +checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" + +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"] +checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" + +[[tools."github:jellyfin/jellyfin-ffmpeg"]] +version = "7.1.3-6" +backend = "github:jellyfin/jellyfin-ffmpeg" + [tools."github:jellyfin/jellyfin-ffmpeg".options] asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" @@ -112,17 +129,12 @@ version = "7.1.3-6" backend = "github:jellyfin/jellyfin-ffmpeg" [tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" +asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"] -checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" +[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"] +checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2" +url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz" +url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889" [[tools."github:jellyfin/jellyfin-ffmpeg"]] version = "7.1.3-6" @@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1 url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094" -[[tools."github:jellyfin/jellyfin-ffmpeg"]] -version = "7.1.3-6" -backend = "github:jellyfin/jellyfin-ffmpeg" - -[tools."github:jellyfin/jellyfin-ffmpeg".options] -asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" - -[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"] -checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2" -url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz" -url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889" - [[tools."github:webassembly/binaryen"]] version = "version_124" backend = "github:webassembly/binaryen" diff --git a/mise.toml b/mise.toml index 3e45d24223..0b11993aa9 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ -experimental_monorepo_root = true +monorepo_root = true [monorepo] config_roots = [ @@ -35,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" } macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" } [settings] -experimental = true pin = true lockfile = true From a0c7b811422921285364850cd72347c4ab853105 Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Wed, 22 Jul 2026 16:30:52 +0530 Subject: [PATCH 033/163] fix(server): dissolve stack when its non-primary assets are deleted (#29354) --- server/src/services/asset.service.spec.ts | 28 +++++++++++++++++++++++ server/src/services/asset.service.ts | 25 ++++++++++++-------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 75e7fc5e87..0264484bfc 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -568,6 +568,34 @@ describe(AssetService.name, () => { expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId); }); + it('should delete the stack when a non-primary asset is deleted and only the primary would remain', async () => { + const asset = AssetFactory.from().build(); + const deletionAsset = { + ...getForAssetDeletion(asset), + stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }] }, + }; + mocks.stack.delete.mockResolvedValue(); + mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset); + + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + + expect(mocks.stack.delete).toHaveBeenCalledWith(deletionAsset.stack.id); + }); + + it('should keep the stack when a non-primary asset is deleted and the primary plus another asset remain', async () => { + const asset = AssetFactory.from().build(); + const deletionAsset = { + ...getForAssetDeletion(asset), + stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }, { id: newUuid() }] }, + }; + mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset); + + await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true }); + + expect(mocks.stack.delete).not.toHaveBeenCalled(); + expect(mocks.stack.update).not.toHaveBeenCalled(); + }); + it('should delete a live photo', async () => { const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build(); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c27bbd4b18..1edccf9483 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -316,18 +316,25 @@ export class AssetService extends BaseService { return JobStatus.Failed; } - // replace the parent of the stack children with a new asset - if (asset.stack?.primaryAssetId === id) { - // this only includes timeline visible assets and excludes the primary asset - const stackAssetIds = asset.stack.assets.map((a) => a.id); - if (stackAssetIds.length >= 2) { - const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!; + if (asset.stack) { + // asset.stack.assets only includes timeline visible assets and excludes the primary asset + const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id); + + // the primary survives unless it is the asset being deleted + let remainingCount = remainingStackAssetIds.length; + if (asset.stack.primaryAssetId !== id) { + remainingCount++; + } + + if (remainingCount < 2) { + // 0 or 1 asset would remain: dissolve the stack so it does not linger as a single-asset stack + await this.stackRepository.delete(asset.stack.id); + } else if (asset.stack.primaryAssetId === id) { + // the primary is being deleted but others remain: promote a new primary await this.stackRepository.update(asset.stack.id, { id: asset.stack.id, - primaryAssetId: newPrimaryAssetId, + primaryAssetId: remainingStackAssetIds[0], }); - } else { - await this.stackRepository.delete(asset.stack.id); } } From 12f741d9112afabd6c68319de46232efc7a40cbc Mon Sep 17 00:00:00 2001 From: Jan Bader Date: Wed, 22 Jul 2026 13:25:29 +0200 Subject: [PATCH 034/163] chore(cli): remove -h shorthand for --skip-hash (#30119) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Co-authored-by: Daniel Dietzler --- e2e/src/specs/server/cli/upload.e2e-spec.ts | 2 +- packages/cli/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/src/specs/server/cli/upload.e2e-spec.ts b/e2e/src/specs/server/cli/upload.e2e-spec.ts index 5df36746d7..6ed5030e83 100644 --- a/e2e/src/specs/server/cli/upload.e2e-spec.ts +++ b/e2e/src/specs/server/cli/upload.e2e-spec.ts @@ -650,7 +650,7 @@ describe(`immich upload`, () => { ]); expect(stdout).toBe(''); - expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`); + expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`); expect(exitCode).not.toBe(0); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c82c7d852b..deef7d8c8c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -47,7 +47,7 @@ program .usage('[paths...] [options]') .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .addOption(new Option('-i, --ignore ', 'Pattern to ignore').env('IMMICH_IGNORE_PATHS')) - .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) + .addOption(new Option('--skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false)) .addOption( new Option('-a, --album', 'Automatically create albums based on folder name') From 457da17c5d02f8f8d2cd4c6c50ace402ffc4873b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 22 Jul 2026 14:59:54 +0200 Subject: [PATCH 035/163] fix: always set extension from provided file (#29839) * fix: always set extension from provided file The livephoto case can also happen with other files; for example, we receive a DNG file as JPEG but then still call it .dng Fixes #29805, partially addresses #28602 * chore: test cases for file extension naming --- .../services/background_upload.service.dart | 11 ++- .../services/foreground_upload.service.dart | 15 ++-- .../background_upload.service_test.dart | 45 ++++++++++++ .../foreground_upload.service_test.dart | 72 +++++++++++++++++++ 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index fa0a41f17c..ec731c6f68 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -290,13 +290,10 @@ class BackgroundUploadService { return null; } - String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; - final hasExtension = p.extension(fileName).isNotEmpty; - if (!hasExtension) { - fileName = p.setExtension(fileName, p.extension(asset.name)); - } - - final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; + // Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those. + final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); + final originalFileName = p.setExtension(fileName, extension); String metadata = UploadTaskMetadata( localAssetId: asset.id, diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 06915ad09f..cce1241473 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -309,17 +309,10 @@ class ForegroundUploadService { return; } - String fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; - - /// Handle special file name from DJI or Fusion app - /// If the file name has no extension, likely due to special renaming template by specific apps - /// we append the original extension from the asset name - final hasExtension = p.extension(fileName).isNotEmpty; - if (!hasExtension) { - fileName = p.setExtension(fileName, p.extension(asset.name)); - } - - final originalFileName = entity.isLivePhoto ? p.setExtension(fileName, p.extension(file.path)) : fileName; + final fileName = await _assetMediaRepository.getOriginalFilename(asset.id) ?? asset.name; + // Some apps (e.g. DJI/Fusion) return names without an extension; fall back to the asset name for those. + final extension = p.extension(file.path).isNotEmpty ? p.extension(file.path) : p.extension(asset.name); + final originalFileName = p.setExtension(fileName, extension); final deviceId = Store.get(StoreKey.deviceId); final fields = { diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index 527da1bf1b..65f17501a0 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -136,6 +136,51 @@ void main() { expect(task, isNotNull); expect(task!.fields.containsKey('visibility'), isFalse); }); + + test('corrects the extension when iOS returns a rendered file for a .dng asset', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/IMG_6499.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('IMG_6499.jpg')); + }); + + test('keeps the .dng extension for a genuine RAW original', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/IMG_5210.dng'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('IMG_5210.dng')); + }); + + test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final mockFile = File('/path/to/DJI_0001'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => mockFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001'); + + final task = await sut.getUploadTask(asset); + expect(task, isNotNull); + expect(task!.fields['filename'], equals('DJI_0001.jpg')); + }); }); group('getLivePhotoUploadTask', () { diff --git a/mobile/test/services/foreground_upload.service_test.dart b/mobile/test/services/foreground_upload.service_test.dart index ed48270b87..46e9a82141 100644 --- a/mobile/test/services/foreground_upload.service_test.dart +++ b/mobile/test/services/foreground_upload.service_test.dart @@ -81,6 +81,24 @@ void main() { return captured; } + List captureOriginalFileNames() { + final captured = []; + when( + () => mockUploadRepository.uploadFile( + file: any(named: 'file'), + originalFileName: any(named: 'originalFileName'), + fields: any(named: 'fields'), + cancelToken: any(named: 'cancelToken'), + onProgress: any(named: 'onProgress'), + logContext: any(named: 'logContext'), + ), + ).thenAnswer((invocation) async { + captured.add(invocation.namedArguments[#originalFileName] as String); + return UploadResult.success(remoteAssetId: 'remote-${captured.length}'); + }); + return captured; + } + group('uploadSingleAsset', () { test('should upload the motion part hidden and keep the still image visible', () async { final asset = LocalAssetStub.image1; @@ -124,5 +142,59 @@ void main() { expect(captured, hasLength(1)); expect(captured[0].containsKey('visibility'), isFalse); }); + + test('corrects the extension when iOS returns a rendered file for a .dng asset', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/IMG_6499.jpg'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_6499.dng'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['IMG_6499.jpg'])); + }); + + test('keeps the .dng extension for a genuine RAW original', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/IMG_5210.dng'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'IMG_5210.dng'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['IMG_5210.dng'])); + }); + + test('borrows the extension from the asset name for an extensionless name (DJI/Fusion)', () async { + final asset = LocalAssetStub.image1; + final mockEntity = MockAssetEntity(); + final stillFile = File('/path/to/DJI_0001'); + + when(() => mockEntity.isLivePhoto).thenReturn(false); + when(() => mockStorageRepository.getAssetEntityForAsset(asset)).thenAnswer((_) async => mockEntity); + when(() => mockStorageRepository.isAssetAvailableLocally(asset.id)).thenAnswer((_) async => true); + when(() => mockStorageRepository.getFileForAsset(asset.id)).thenAnswer((_) async => stillFile); + when(() => mockAssetMediaRepository.getOriginalFilename(asset.id)).thenAnswer((_) async => 'DJI_0001'); + + final names = captureOriginalFileNames(); + + await sut.uploadSingleAsset(asset, null, callbacks: const UploadCallbacks()); + + expect(names, equals(['DJI_0001.jpg'])); + }); }); } From 23778551f7960e1953b46010c44eb9b40b583753 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:21:33 +0530 Subject: [PATCH 036/163] fix: locked view and asset view provider (#30136) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/providers/routes.provider.dart | 15 ++++++-- .../lib/routing/app_navigation_observer.dart | 35 ------------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart index a752a51a7d..6cc3d08c5d 100644 --- a/mobile/lib/providers/routes.provider.dart +++ b/mobile/lib/providers/routes.provider.dart @@ -1,8 +1,19 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/routing/router.dart'; + +@visibleForTesting +bool isRouteInStack(Ref ref, String routeName) { + final router = ref.watch(appRouterProvider); + void onChange() => ref.invalidateSelf(); + router.addListener(onChange); + ref.onDispose(() => router.removeListener(onChange)); + return router.stackData.any((route) => route.name == routeName); +} + +final inLockedViewProvider = Provider((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name)); +final isAssetViewerOpenProvider = Provider((ref) => isRouteInStack(ref, AssetViewerRoute.name)); -final inLockedViewProvider = StateProvider((ref) => false); -final isAssetViewerOpenProvider = StateProvider((ref) => false); final currentRouteNameProvider = StateProvider((ref) => null); final previousRouteNameProvider = StateProvider((ref) => null); final previousRouteDataProvider = StateProvider((ref) => null); diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 09d17da182..57304af44f 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; class AppNavigationObserver extends AutoRouterObserver { /// Riverpod Instance @@ -12,46 +11,12 @@ class AppNavigationObserver extends AutoRouterObserver { AppNavigationObserver({required this.ref}); - @override - Future didChangeTabRoute(TabPageRoute route, TabPageRoute previousRoute) async { - unawaited(Future(() => ref.read(inLockedViewProvider.notifier).state = false)); - } - @override void didPush(Route route, Route? previousRoute) { - _handleDriftLockedFolderState(route, previousRoute); Future(() { ref.read(currentRouteNameProvider.notifier).state = route.settings.name; ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name; ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings; - if (route.settings.name == AssetViewerRoute.name) { - ref.read(isAssetViewerOpenProvider.notifier).state = true; - } }); } - - @override - void didPop(Route route, Route? previousRoute) { - _handleDriftLockedFolderState(previousRoute ?? route, null); - if (route.settings.name == AssetViewerRoute.name) { - Future(() => ref.read(isAssetViewerOpenProvider.notifier).state = false); - } - } - - _handleDriftLockedFolderState(Route route, Route? previousRoute) { - final isInLockedView = ref.read(inLockedViewProvider); - final isFromLockedViewToDetailView = - route.settings.name == AssetViewerRoute.name && previousRoute?.settings.name == DriftLockedFolderRoute.name; - - final isFromDetailViewToInfoPanelView = - route.settings.name == null && previousRoute?.settings.name == AssetViewerRoute.name && isInLockedView; - - if (route.settings.name == DriftLockedFolderRoute.name || - isFromLockedViewToDetailView || - isFromDetailViewToInfoPanelView) { - Future(() => ref.read(inLockedViewProvider.notifier).state = true); - } else { - Future(() => ref.read(inLockedViewProvider.notifier).state = false); - } - } } From b9f6c4aaf22fd032cf1cc4357db06fe9ca4e3abc Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 22 Jul 2026 12:09:05 -0400 Subject: [PATCH 037/163] feat: password invalidate sessions (#30125) --- .../img/reset-admin-password.webp | Bin 9066 -> 0 bytes docs/docs/administration/server-commands.md | 1 + .../immich-admin/immich-admin.e2e-spec.ts | 4 ++++ server/src/commands/index.ts | 4 ++-- .../commands/reset-admin-password.command.ts | 20 +++++++++++++----- server/src/services/cli.service.spec.ts | 19 +++++++++++++++-- server/src/services/cli.service.ts | 10 +++++++-- 7 files changed, 47 insertions(+), 11 deletions(-) delete mode 100644 docs/docs/administration/img/reset-admin-password.webp diff --git a/docs/docs/administration/img/reset-admin-password.webp b/docs/docs/administration/img/reset-admin-password.webp deleted file mode 100644 index 5062d24a6b79f8515e8b78a8fd743965a171c34d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9066 zcmV-wBbD4zNk&FuBLDzbMM6+kP&gn~BLDypegK^TD$fC(06x)Rs7_0+!jrJed1Nq;UU+H`gzT^If_K)Bb`M38E{a@C9!2B=&VgJwl zmz{5_Kl*>C_z3=s{lDwyz^A3*t97&vE2ObTN%!M0ZGEvCIxRnMjk9c*i10>2;t^lk zGYDL|fo0Y#+Zq;MP?`He-I+)j|Aq;4Q_vik&sCF4Z^h-2r9mcwJ1~f9i~Eia-i;Dw z)0Zz4le0%AjC&hAb61^?)n;W}Z4X|goP>(#Xj2hZV|Uy=4#V|}EPbs}A22a@>38J! ze+#4sOq+yLjNb-lcdM6GCx=M^tmf!sPYXpa5C=-lNJA7#ny5>xK057I$I=!@=S-d{ zcaZAwW`5a!)F-{OrS-Bl5HzBaA&90`s#u|V5#LH+HYOTKE`C!zz7%CwJrxD~n1MS| zq~cmTuJM7qk+STgFfFJD%o%OL!o_WMB!F6`1>45Ell!jF0spnZ z#Bgv1m8aEevT$K&X@RnNInY%-DOcb9fs@*^orF6KkAypgX^E^UVIAYR>c^5DzC2%? z(T(l*Y(lDt79O(Pop!Jm0X+qoUcEg4=@7h+heC`ZQ?!WV${?!mC!_XHCrE|+GxfiT3_x0dqTfXyRcH=U z-7J&6WF}Bvz;|CKozM85{=AUZ5XjQcg{pU~xF6&-JLj40?x^JQs3KnG=vzCiBqj$d z693QpvyrBFYdRuVQ*Nm9mfQHQK4x0_{MUymXytw;83q{q`{2X2ry$WrkT(%OW@n11Q}e>*aJj$ zE``V05kiDUeeFZ^@)%88-@_$7b6W!;Nx;_@%Ah%~VIM(!OJPB?qiLi&4CH-AXuoED z+G|+=0RF}w8Oz_aXtp`mgH61-d6DsM7~?9sx@-5-e@*xS^KgFs1UvH(qg$TaWbFd@Os8E?FT8 zZCr|sM3)tXnyu)VY#{^4D&=OK6|652~bJ53bIX#&f#Le#g!lEmgO*cJ@XJMbQ zu|STBW7_$X@WpmV?zokAE3FB9NLdCE!ZD@lkS)LqX$!?JN`OSnu6%S|M{vN(?1X*5 zpDHxm1*d-hNXiOZolJkJT>S{bp&47_Y^$9kz(3;WVp8e53sB&bS7_+|uag-&hPgMa z^08dQUVteP8m@mcH(sGNh6Rr^*V(C&>=%-BD<{6XcT{C2bRV6h z>FtvIM4CNH8Js3?nJn@7>5o~74D_n&Xj;gm)zk{99ey~zC^M*1#9UlCcN)1>r=7Oj zx6>8Fzmjjk9MqSK{P-`z5;M{BbzY8Lnkf5sR&mwEP5_Ks-SJexm?X7M$zd1du9n(R z^(6-KGd+(M2B7+Q1(%+@o)p2r_y$}OGijc2*e*~YYi2?t*|h;m=kXN`KrhFkQVb&! zyvCczrkp*Lp^WFJhdWW9Xm^-Zu~8J@OVFMmAoyw$9ZoR}>K1#=DR6j9znX9|O(#5t2f zPfl*bTfeB%6RMuKb0YuZ07RYuObgp@&sX>7oYzLCxomMF^u>0jsNu;(i#n&4h}cqi zz*P?%UxiDirLJWZ!#0tJ9bNbFBg$oP#o7l--zals`bMMKSq^;rk#&;QWpJY2H{6uZ zgfz5lHiz&si!nLOX{S{~`?~a_l{7g;9q0lrL0^J*VG~xff6S3*8s2f)F2 z)qefm9F(zhQi9}sZzcb2eGeyJhuG-|Qpg&D{x4zHQx$KWfbYo7G!d@&w17lxaL*uQ zR&V+gCSD06M)!a_5fP^&yG&8JqS`Dl-!lZ&n)_X;P+g(e&kuGyk#9FmGpWVOh9U$o z?Vi2KWS|G!s!n*Fasr9b3cWoC{?Bnwll7#!VU3dN@@Yn#?+ad=r$Rpe08v0-*4px-sl))b&ljTsOu zv(xoWM84phz#o}d;(Qx6$NCYYAyevmE$|<7vK*Dz+6#5w#fst(hN*Oth-H2#$=T#1 z3m7!j|9h`svQvg@=14b;>h=KHWQvb;YN1F1Vdnr9!@)YXV!wO$F;>Y=2+)>v^PLB0 zu9?Nrz@_U=q~WHJ@)m3ENq)yC>sk|Fs0~9qVxUj$K2vs3WuED<;GDA-&K zUKqd`hQq2eX=Zmvyc$Ar1d$yEI}MTqkwEhbuEo9SacjVl@3l|Itdnacg@xpI6MQfB z^tMvl$LF{wPKzGX+5#5#%jB$ee))#;zL>d9}UPjk7;#A{QkZ7K9+R8V}ndB?HYyp{$4 z1F}!?>ft89t_wFkF~t$ESA0J1*U3&Je`6eA+zn6toz8H}(;n;vqgnatSbtcd=1J6qB5$9pR!Y=J_qw$> zOXV%`HqCxyLUFLGTiOPTLr zH>t720=m-0%v%6#kPC{}m~=r9#-rI$r{3&^aMQ`W9FMUW_t)l6#5X$e;73n?|Mu#p zh6+0-AP6X=uA)rN=mnSbXFIm;-bnw8gn&@@{l4;*E7_9}f+)X$inm2_1Mba5>-X!^ z6?g1hgWXOkczyJl^+>WZ6ha|3{(1KB6u`}o!UXYSqv8hIy`f0U%6VIT4xT(;kL`ft znCKJOB&}fOuWSb#$3ULJC2JM#Yq_8EnjD0M)Qs_kGMq|sT`_1A+4m27uyZ-&2a`6jdo zw~@T2ifPmR8(IT1-V1ub8nl_aH6tN~(KV{v_Pu;LiP5?{O5j_JDfFjw@*}UBr=Fe$QcgRK3_Lq-TJv)cmv7W{2OVJbeU2r@LJcxlCdFYa} zbY*qHsSY0XbN~Qjvz)cn*#Ipp3;-*lcz*#+5%QaL##Dfer_N@Bbez3L+*0AQSz0;7 zoxK>E!>5@yDMvWUUT@o_@FAodl|t^DYr}M01IlH)2QG%rf~9fD8{~df<~$#$%4cvx z@H`YmxQF^s>*#t!?LW#Li^W)7s|M2Wgpn=%9}7LK9s5cjBqi2=u;?6~)71hdDiktu-q0zZg>`-9u zo8*efjk*b#a1v4}au6e2@UoX=Q#l7m+plIeB(QZVgr>IC^axH}Nl=8d{{$jh?Mze3 z%lFe(R!2Yv))g5D@<3HmIA!D+ENHYmV>;;`E|Ii3ePpC2nPX)2(*n|*Eb?=VfzMFS zOaTV(@Sw?HV$y}R3c$^M~7|qC0&BgXs9PSDczgL2jgcny zOz5-mEx+hP=G?3UyRUsC%QL*;zqck2?O5UuX3CxYz%!|hP9ZE8hiq28kLo%E6gq|^ z-VSaN3-b9Wy-?jwAkN|yjCl{uL;S4%p!Lw+?Ds|L3Xx!;&Vn$14cPV$DXyElPC(4B z?qyhQb26HEPsPH2F?Ktfudz$LW>5j-sRuf7anDJa*M9H?Sa=t(`8l6yiZ?V{*FS<= z2T7<=kpju7RUTdd1Cy%fzy!~Z?PaGxss+9{5<G3j`GyBu@JYe>v>qiDX3dV7eOa$@fbCbx@eU`#;bfbHK;}!>>#!4 zfO8KyM=~@-m%)!KjZRI986qEtIZMa&r6Lw4+2jbq)Bp^QXH+tlqPRI-mNg3dSS4Fn zjmLAW+QJ#|eT4F4$5v2$2B{xLe(jRN4uyLV#w*MbwQ8nlXO$Z+? z3$Dt}!2>Y^lYnC?$GZ-DPr<3H@`qPV8`s52ppuy}lOrvY^q0%OVCGwR7c$y6J`Xek zZBys?g|n?P*J9kKSLuHFC=CQtI4g-NZRa+^=XDo8*TvnLBCFTSrMB;Q7ItQ^>xVAm zRsf6v<^xevC^(a!9`@(1?dO|))6>QOLWix;Q+@M-REnZ<{puMyk!T+Z+GqvZ&nsV zb2p?oyJaHFcwO6Q9&MX*bPCtn+sn9T+pZHACXJd!2`z(}`LxN+zo`1=HAY*Lp)|p> z3DetZDrP3ttHD@XJm@FH4KCo^F0D$>f+Qnh$I{8~&_Yj|&9bAR2`PTORpItcysGn9 za0rwJojMo}xuD7LyO-G27iHbdJ<8X|=2gtr(VIcd71Z}^z6`RJodC%#?XFk7(YeFM zEr(iaSP(tz1AqVkt~TMjcuI0~zB*7;=|s=#b_b6)_Kz+yg9}jyGzmArgg(o-=0-Mb z0h1@)4!U)E>wAOQq7~C|bJ)FuM$C?oshH#BMvQ_jy0-1%>IdS^Uf4`P;kqu|6>Pf? z?#4ySf0HIKU|(k*dZ!B;p(xw(yhLKc#835sK9|2){*AIa>p>Nzo0iZW>YW<)dD%+hy?CHTd5tRp=SBvS(V7Gq5$ClG}Lry>gkT1FYe$qn&`_v$h(ln|gf zTNK<$E7_mCQ4XDm&vr<1jh%s-hHv)#7}Fyh96OW)z+`{dNvF{p`adD&bKI53qYyBT zSTlkf!)>n86Hc}5`^@$R@k`_ycc&JmSQCfxxYY<;1>R>p2YD)xZ#~(ec8%6?Wgecm z!w8lEHti??000Cj-ysp}(?tQ0v8cz+9h3azOPgaVSa_`q-~^X;eJ^fQBt@#tgwrVl z&#u!QMwpef%}8%ipQw)O0|V6~GKDil=tA<0p@TVdTlAa@39R(f0(>W5qiS~qS~**R zeq=6mdQP0eu|&lzW;?q7cC%f&H1OZVPf%-a4QxC#)?y~byBrZRC9(i9Il+a2tV3PJRySz0hfhvv}F#5KajL7L=; zI5GE!MRZ3+_uP!Wfu@oZh(O2lQswODn`6C{bKzkX&8p`jezdWVY^lySn!CK?6ZK6F zX~uu^oG>$~?daaSmLb8kEqWQgA^{_YF&iu8MlA zU7YJs__NJ?Bv3a|&$Y`c4V%oOVjPbPC(_p&uJo-RE2j2o`-oiCnm|e|v}JA2OmgeT11ai!(-orSgs&+DRmeNHbp$`dxh!dTwL z*?DoiJHul5ZNo}Y-_w_*ndY8?b8}1d#{#txk8=P+-sD3K74e($W+{WlU7$lhP&-GNh#ry&VUy0H~G z5Qca84jg<+=cBqAK-=8qcWr2+3+m{k!JN@LnPX4Ln18Y_}N} zZ*uI69y8{!d#&o4`7G$kkVhKD??B28oO_H|IzDe9DL_uqfl@_^B4^PkJ7EjQK=R*v&l+RFh_x1y|6~r%jx8w zCL830yvCg<&@8dV0uuR-32tD5Oz8+(IGp(ji@@UbSMEi4(igQrppv_3xDk*Nt0ZN} zV0solHKxx#IYoIkG)}D~o8n*>Z!>sJ11?t&E!WjM86_He15+z86u$%t=9&skF zu7KeWM1w7k#R($M84aDWo;zFet*1X54lW+mam8@YXRi}+3d#7HfG6)L#*^F`&AAi! zJMzoiK%#`nH;YiZNiLfr+Z(rP_@aTUh3~pI(RDiWhW>X$^BMbqv4>TOAY$(Vi5p@z z$y#04-7Rw3)7BPe;&S$aS|6moUH^I?p|PR{?xuCDmy46bR$=LPe%U}Km|`slRm3r{ z3-MmVUc)xhE@hL|9fOpt3~{m8dnsY`cDMtft6v%J*TLc-0t60jS7xKfU=yw!yNy@^ zFb9|oM7b7_Xc7#3l|*cAIv4Cw(MNX##Pf2vCPxpcnnb z^s`*|8IihdgahSroc__l*I{0SFj~WFx}r@jzRn1U%^f*~U5vRz%9BSK$l!Mc^qI0T zL#5)@z7hJJG>)wP>_h(nz?R6SQ3z} z^a7uXO~Gd+_dI@Y=@@hQ{$YZ$uU%txS<%51SoEoIbTW{86E06~Eex}<1_ITL>tyJM zjPD|4?aw7Z;|ue@H_3yQbCSfz{!~sYf{rSW7?F`?{T735Y!9-woZkk+Fw~DkDeEj}BBmTYm(7C`2)~`lMeh?B8_4%P) z;&|sRl3XsqR(eF~5y*VvLQO5c&IpLbpeu;q)rkD%XxwR`pmV?~vE-+8~f_CdZqfL`KrMyxtR(^qjXI^e+Hb zS7M@0{B3|)Y9?Te%Yu;BIGgK$G4h_!gmc`Na-XVi-5{uQrh_q(km!x!ax_6<7{2XX zt?)4{$ifl-ustm^!KeJ|g?5L~Ipauj(3>C5L`3S=gwO37Y}rMiZ>YL=Zp!v##0b=6 z6&Sopnjb|pUMZ+(?>_3Fk!0<2ZR_B#1{^OcJKtQy5vj3<{3aulP}wc6KNReNhj!Rv z7D1l}hSe1R)m*$VTElWGblD3V@@2RVA)}w~{SakzVVBA@N*JHZ1xf$*0eR_+6EQOd`&;rR7FMo?g)r zVDgwEAD2jNmQlszBZNv2I

YEkRBU8M`o~BezqD4f6Q~J^zvjGDAVO=oXDr!V) z27_txasRk759K3>Sft;0OiXC0^>s5sew*sT=+pW;XkWE20b}nz-{p2TprLXd8S9$| zTDYhJXbAyNI}zOJ_SUIb{V;WKH@9Er)wz$AQtXx+nT*(&>4(%DVd(@)!J#mr22+(XAJ;S@K4L95G8q)Yq_q!oig>64N&UbYKin;c%`}*%;asf zTL?~~3AJs|Wrf4Y)HtMb6?hs|N{!``48Q0|Uf~P%#^oc}>KuODSKm7+nz%4u7u7=U zmfRIl1QJb7x9_nljgF4|4V7EXn(dPEVyAVc`54lCJfUk6zA{f~*OaB7`lQupJM{%Sr{7sp+%E3u0Nr%_vfujT5d| z8R=7`!T3EN{_1JSRl@}I+2Ust1Q{{;N!Us}V&m2Tqj=4e4kXe_X>hFYAyD@}GF~`% z{19p3ew(?jVKFEBX2@{;WrwM(J@SHzB@zRvovAqL?;`j$xJug)4=?8J|IgLVis4wh crc{x#J5U5KxuQ_*Qb|g9NTmP(000000D7XF4FCWD diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 104c16c0a2..49bcf17700 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -35,6 +35,7 @@ Found Admin: - Email=admin@example.com - Name=Immich Admin ? Please choose a new password (optional) immich-is-cool +? Invalidate existing sessions? Yes The admin password has been updated. ``` diff --git a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts index 24699cda30..ad0e43ff14 100644 --- a/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts +++ b/e2e/src/specs/server/immich-admin/immich-admin.e2e-spec.ts @@ -65,6 +65,10 @@ describe(`immich-admin`, () => { child.stdout.on('data', (chunk) => { data += chunk; if (data.includes('Please choose a new password (optional)')) { + child.stdin.write('\n'); + } + + if (data.includes('Invalidate existing sessions?')) { child.stdin.end('\n'); } }); diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 2a2dd1857d..0d03e4cd95 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -8,13 +8,13 @@ import { } from 'src/commands/media-location.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; -import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; +import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { SchemaCheck } from 'src/commands/schema-check'; import { VersionCommand } from 'src/commands/version.command'; export const commandsAndQuestions = [ ResetAdminPasswordCommand, - PromptPasswordQuestions, + PromptPasswordResetQuestions, PromptEmailQuestion, EnablePasswordLoginCommand, DisablePasswordLoginCommand, diff --git a/server/src/commands/reset-admin-password.command.ts b/server/src/commands/reset-admin-password.command.ts index e5dee49837..703f693326 100644 --- a/server/src/commands/reset-admin-password.command.ts +++ b/server/src/commands/reset-admin-password.command.ts @@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { CliService } from 'src/services/cli.service'; const prompt = (inquirer: InquirerService) => { - return function ask(admin: UserAdminResponseDto) { + return (admin: UserAdminResponseDto) => { const { id, oauthId, email, name } = admin; console.log(`Found Admin: - ID=${id} @@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => { - Email=${email} - Name=${name}`); - return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password); + return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {}); }; }; @@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner { } } -@QuestionSet({ name: 'prompt-password' }) -export class PromptPasswordQuestions { +@QuestionSet({ name: 'prompt-password-reset' }) +export class PromptPasswordResetQuestions { @Question({ message: 'Please choose a new password (optional)', - name: 'password', + name: 'newPassword', }) parsePassword(value: string) { return value; } + + @Question({ + type: 'confirm', + message: 'Invalidate existing sessions?', + default: true, + name: 'invalidateSessions', + }) + parseInvalidate(value: boolean): boolean { + return value; + } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 347d9eef00..29c62d258f 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -37,7 +37,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true })); - const ask = vitest.fn().mockImplementation(() => {}); + const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -47,6 +47,7 @@ describe(CliService.name, () => { expect(ask).toHaveBeenCalled(); expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); + expect(mocks.session.invalidateAll).not.toHaveBeenCalled(); }); it('should use the supplied password', async () => { @@ -55,7 +56,7 @@ describe(CliService.name, () => { mocks.user.getAdmin.mockResolvedValue(admin); mocks.user.update.mockResolvedValue(admin); - const ask = vitest.fn().mockResolvedValue('new-password'); + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false }); const response = await sut.resetAdminPassword(ask); @@ -66,6 +67,20 @@ describe(CliService.name, () => { expect(id).toEqual(admin.id); expect(update.password).toBeDefined(); }); + + it('should invalidate existing sessions when requested', async () => { + const admin = UserFactory.create({ isAdmin: true }); + + mocks.user.getAdmin.mockResolvedValue(admin); + mocks.user.update.mockResolvedValue(admin); + mocks.session.invalidateAll.mockResolvedValue(void 0); + + const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true }); + + await sut.resetAdminPassword(ask); + + expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id }); + }); }); describe('disablePasswordLogin', () => { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 23d70b6860..f37353d7a1 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -58,18 +58,24 @@ export class CliService extends BaseService { return users.map((user) => mapUserAdmin(user)); } - async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise) { + async resetAdminPassword( + ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>, + ) { const admin = await this.userRepository.getAdmin(); if (!admin) { throw new Error('Admin account does not exist'); } - const providedPassword = await ask(mapUserAdmin(admin)); + const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin)); const password = providedPassword || this.cryptoRepository.randomBytesAsText(24); const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS); await this.userRepository.update(admin.id, { password: hashedPassword }); + if (invalidateSessions) { + await this.sessionRepository.invalidateAll({ userId: admin.id }); + } + return { admin, password, provided: !!providedPassword }; } From a7f1d495c6cd3a5f51f922ad6a808e2cc774a8c1 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 22 Jul 2026 11:44:14 -0500 Subject: [PATCH 038/163] fix: wrong corner radius of recently added link (#30140) --- mobile/lib/presentation/pages/search/drift_search.page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 2ae6371f6a..22546b8e50 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget { _QuickLink( title: context.t.recently_added, icon: Icons.upload_outlined, - isTop: true, onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()), ), _QuickLink( From 564cda50880c97af12b0175d80167d1720ef8ce4 Mon Sep 17 00:00:00 2001 From: Pavel Miniutka Date: Wed, 22 Jul 2026 20:53:21 +0300 Subject: [PATCH 039/163] chore(mobile): Adds Belarusian language option in settings on mobile (#29939) chore(mobile): add missing Belarusian (be) language option in settings --- mobile/lib/constants/locales.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/constants/locales.dart b/mobile/lib/constants/locales.dart index 3082a1a0dd..ed87deab8a 100644 --- a/mobile/lib/constants/locales.dart +++ b/mobile/lib/constants/locales.dart @@ -6,6 +6,7 @@ const Map locales = { // Additional locales 'Arabic (ar)': Locale('ar'), 'Basque (eu)': Locale('eu'), + 'Belarusian (be)': Locale('be'), 'Bosnian (bl)': Locale('bn'), 'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'), 'Bulgarian (bg)': Locale('bg'), From 1f81eac8ab3989a5b46721c498d1731a871ee0ed Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:18:28 +0530 Subject: [PATCH 040/163] refactor: toast repository (#29386) refactor: feedback repository Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../infrastructure/toast.provider.dart | 4 +++ mobile/lib/repositories/toast.repository.dart | 26 +++++++++++++++++++ mobile/packages/ui/lib/src/snackbar.dart | 22 +++++++++++----- 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 mobile/lib/providers/infrastructure/toast.provider.dart create mode 100644 mobile/lib/repositories/toast.repository.dart diff --git a/mobile/lib/providers/infrastructure/toast.provider.dart b/mobile/lib/providers/infrastructure/toast.provider.dart new file mode 100644 index 0000000000..27d1cf9e6b --- /dev/null +++ b/mobile/lib/providers/infrastructure/toast.provider.dart @@ -0,0 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/repositories/toast.repository.dart'; + +final toastRepositoryProvider = Provider((ref) => const .new()); diff --git a/mobile/lib/repositories/toast.repository.dart b/mobile/lib/repositories/toast.repository.dart new file mode 100644 index 0000000000..0cca50fdec --- /dev/null +++ b/mobile/lib/repositories/toast.repository.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:immich_ui/immich_ui.dart'; + +class ToastOption { + final Duration? timeout; + final FutureOr Function()? onUndo; + + const ToastOption({this.timeout, this.onUndo}); +} + +class ToastRepository { + const ToastRepository(); + + FutureOr success(String message, {ToastOption? toast}) { + snackbar.success(message, duration: toast?.timeout); + } + + FutureOr info(String message, {ToastOption? toast}) { + snackbar.info(message, duration: toast?.timeout); + } + + FutureOr error(String message, {ToastOption? toast}) { + snackbar.error(message, duration: toast?.timeout); + } +} diff --git a/mobile/packages/ui/lib/src/snackbar.dart b/mobile/packages/ui/lib/src/snackbar.dart index a44be8d513..1ede1124a8 100644 --- a/mobile/packages/ui/lib/src/snackbar.dart +++ b/mobile/packages/ui/lib/src/snackbar.dart @@ -6,18 +6,23 @@ final scaffoldMessengerKey = GlobalKey(); class SnackbarManager { const SnackbarManager(); - ScaffoldFeatureController? show(String message, SnackbarType type) { + ScaffoldFeatureController? show( + String message, + SnackbarType type, { + Duration? duration, + }) { final messenger = scaffoldMessengerKey.currentState; final context = scaffoldMessengerKey.currentContext; if (messenger == null || context == null) { return null; } + duration ??= const .new(seconds: 4); messenger.hideCurrentSnackBar(); - return messenger.showSnackBar(_build(context, message, type)); + return messenger.showSnackBar(_build(context, message, type, duration)); } - SnackBar _build(BuildContext context, String message, SnackbarType type) { + SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) { final theme = Theme.of(context); final colors = theme.extension() ?? ImmichColors.harmonized(theme.colorScheme); final (IconData icon, Color background, Color foreground) = switch (type) { @@ -29,7 +34,7 @@ class SnackbarManager { return SnackBar( behavior: .floating, backgroundColor: background, - duration: const .new(seconds: 4), + duration: duration, shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))), content: Row( children: [ @@ -48,11 +53,14 @@ class SnackbarManager { ); } - ScaffoldFeatureController? info(String message) => show(message, .info); + ScaffoldFeatureController? info(String message, {Duration? duration}) => + show(message, .info, duration: duration); - ScaffoldFeatureController? success(String message) => show(message, .success); + ScaffoldFeatureController? success(String message, {Duration? duration}) => + show(message, .success, duration: duration); - ScaffoldFeatureController? error(String message) => show(message, .error); + ScaffoldFeatureController? error(String message, {Duration? duration}) => + show(message, .error, duration: duration); } const snackbar = SnackbarManager(); From 9403e71d23202c5d538148c2336b9fc127b43ef5 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:21:06 +0530 Subject: [PATCH 041/163] refactor: add asset update method (#29384) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/constants/enums.dart | 2 -- .../repositories/remote_asset.repository.dart | 17 ++++++++++ .../repositories/asset_api.repository.dart | 32 ++++++++++++++----- mobile/lib/services/action.service.dart | 8 ++--- mobile/lib/utils/option.dart | 13 ++++++++ 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 72479416a8..d59c48c045 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -9,8 +9,6 @@ enum SortOrder { enum TextSearchType { context, filename, description, ocr } -enum AssetVisibilityEnum { timeline, hidden, archive, locked } - enum ActionSource { timeline, viewer } enum ShareAssetType { original, preview } diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index b2cecaca35..db89bfc1fc 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; class RemoteAssetRepository extends DriftDatabaseRepository { @@ -292,4 +293,20 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ..orderBy([(row) => OrderingTerm.asc(row.sequence)]); return query.map((row) => row.toDto()!).get(); } + + Future update( + List remoteIds, { + Option isFavorite = const .none(), + Option visibility = const .none(), + }) { + final companion = RemoteAssetEntityCompanion( + visibility: visibility.toDriftValue(), + isFavorite: isFavorite.toDriftValue(), + ); + return _db.batch((batch) { + for (final remoteId in remoteIds) { + batch.update(_db.remoteAssetEntity, companion, where: (e) => e.id.equals(remoteId)); + } + }); + } } diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 40233e90c4..f6ab726de6 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,12 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; -import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart' hide AssetEditAction; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:openapi/api.dart'; +import 'package:openapi/api.dart' as api show AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility; final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( @@ -41,7 +43,7 @@ class AssetApiRepository extends ApiRepository { return response?.count ?? 0; } - Future updateVisibility(List ids, AssetVisibilityEnum visibility) async { + Future updateVisibility(List ids, AssetVisibility visibility) async { return _api.updateAssets(AssetBulkUpdateDto(ids: ids, visibility: Optional.present(_mapVisibility(visibility)))); } @@ -77,11 +79,11 @@ class AssetApiRepository extends ApiRepository { return _api.downloadAssetWithHttpInfo(id, edited: edited); } - _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { - AssetVisibilityEnum.timeline => AssetVisibility.timeline, - AssetVisibilityEnum.hidden => AssetVisibility.hidden, - AssetVisibilityEnum.locked => AssetVisibility.locked, - AssetVisibilityEnum.archive => AssetVisibility.archive, + api.AssetVisibility _mapVisibility(AssetVisibility visibility) => switch (visibility) { + AssetVisibility.timeline => api.AssetVisibility.timeline, + AssetVisibility.hidden => api.AssetVisibility.hidden, + AssetVisibility.locked => api.AssetVisibility.locked, + AssetVisibility.archive => api.AssetVisibility.archive, }; Future getAssetMIMEType(String assetId) async { @@ -106,6 +108,20 @@ class AssetApiRepository extends ApiRepository { Future removeEdits(String assetId) async { return _api.removeAssetEdits(assetId); } + + Future update( + List remoteIds, { + Option isFavorite = const .none(), + Option visibility = const .none(), + }) { + return _api.updateAssets( + AssetBulkUpdateDto( + ids: remoteIds, + isFavorite: isFavorite.toOptional(), + visibility: visibility.map(_mapVisibility).toOptional(), + ), + ); + } } extension on StackResponseDto { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 8e01777c5d..19782c8512 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -79,17 +79,17 @@ class ActionService { } Future archive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.archive); + await _assetApiRepository.updateVisibility(remoteIds, .archive); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.archive); } Future unArchive(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); + await _assetApiRepository.updateVisibility(remoteIds, .timeline); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); } Future moveToLockFolder(List remoteIds, List localIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.locked); + await _assetApiRepository.updateVisibility(remoteIds, .locked); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked); // Ask user if they want to delete local copies @@ -99,7 +99,7 @@ class ActionService { } Future removeFromLockFolder(List remoteIds) async { - await _assetApiRepository.updateVisibility(remoteIds, AssetVisibilityEnum.timeline); + await _assetApiRepository.updateVisibility(remoteIds, .timeline); await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline); } diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart index d98dad1995..e88ae59c1f 100644 --- a/mobile/lib/utils/option.dart +++ b/mobile/lib/utils/option.dart @@ -1,3 +1,4 @@ +import 'package:drift/drift.dart'; import 'package:openapi/api.dart' show Optional; sealed class Option { @@ -21,6 +22,11 @@ sealed class Option { None() => null, }; + Option map(U Function(T value) f) => switch (this) { + Some(:final value) => Some(f(value)), + None() => None(), + }; + U fold(U Function(T value) onSome, U Function() onNone) => switch (this) { Some(:final value) => onSome(value), None() => onNone(), @@ -65,3 +71,10 @@ extension OptionToOptional on Option { Some(:final value) => Optional.present(value), }; } + +extension OptionToDriftValue on Option { + Value toDriftValue() => switch (this) { + Some(:final value) => Value(value), + None() => const Value.absent(), + }; +} From 36dfc985273558df9a990582d3ff865b6709feef Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 23 Jul 2026 03:02:17 +0600 Subject: [PATCH 042/163] fix(mobile): birthday picker date order follows locale (#29419) * fix(mobile): birthday picker date order follows locale * test(mobile): cover date order locales for the birthday picker --- .../person_edit_birthday_modal.widget.dart | 16 +++++ .../person_edit_birthday_modal_test.dart | 69 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index 7ed02af26b..c194bbc684 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -65,6 +65,7 @@ class _DriftPersonNameEditFormState extends ConsumerState? datePickerColumnOrder(String? pattern) { + if (pattern == null) { + return null; + } + final positions = { + DatePickerViewType.year: pattern.indexOf('y'), + DatePickerViewType.month: pattern.indexOf('M'), + DatePickerViewType.day: pattern.indexOf('d'), + }; + if (positions.values.any((position) => position < 0)) { + return null; + } + return positions.keys.toList()..sort((a, b) => positions[a]!.compareTo(positions[b]!)); +} diff --git a/mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart b/mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart new file mode 100644 index 0000000000..387b0ee903 --- /dev/null +++ b/mobile/test/presentation/widgets/people/person_edit_birthday_modal_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; +import 'package:scroll_date_picker/scroll_date_picker.dart'; + +void main() { + group('datePickerColumnOrder', () { + test('month first (en_US)', () { + expect( + datePickerColumnOrder('M/d/y'), + orderedEquals([DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year]), + ); + }); + + test('day first (pl)', () { + expect( + datePickerColumnOrder('dd.MM.y'), + orderedEquals([DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year]), + ); + }); + + test('year first (ko)', () { + expect( + datePickerColumnOrder('y. M. d.'), + orderedEquals([DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day]), + ); + }); + + test('null pattern falls back to package default', () { + expect(datePickerColumnOrder(null), isNull); + }); + + test('missing field falls back to package default', () { + expect(datePickerColumnOrder('M/y'), isNull); + }); + }); + + group('datePickerColumnOrder with real locale patterns', () { + setUpAll(() async { + await initializeDateFormatting(); + }); + + for (final (locales, order, name) in const [ + ( + ['en', 'en-US', 'en-PH'], + [DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year], + 'month/day/year', + ), + ( + ['en-GB', 'fr', 'fr-FR', 'de', 'de-DE', 'pl'], + [DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year], + 'day/month/year', + ), + ( + ['ja', 'ja-JP', 'zh', 'zh-CN', 'ko', 'ko-KR'], + [DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day], + 'year/month/day', + ), + (['ky'], [DatePickerViewType.year, DatePickerViewType.day, DatePickerViewType.month], 'year/day/month'), + ]) { + for (final locale in locales) { + test('$locale uses $name', () { + expect(datePickerColumnOrder(DateFormat.yMd(locale).pattern), orderedEquals(order)); + }); + } + } + }); +} From f24a128319529fcd9f5d751cb71b70b13a5d8903 Mon Sep 17 00:00:00 2001 From: Timon Date: Thu, 23 Jul 2026 00:33:23 +0200 Subject: [PATCH 043/163] feat(server): new search schemas and query builders (#28686) --- .../src/controllers/memory.controller.spec.ts | 6 +- server/src/database.ts | 30 + server/src/decorators.ts | 2 + server/src/dtos/search.dto.ts | 181 ++- server/src/enum.ts | 9 + server/src/queries/search.repository.sql | 1219 ++++++++++++++++- server/src/repositories/search.repository.ts | 68 +- server/src/utils/database.ts | 496 ++++++- server/src/validation.ts | 2 +- 9 files changed, 1980 insertions(+), 33 deletions(-) diff --git a/server/src/controllers/memory.controller.spec.ts b/server/src/controllers/memory.controller.spec.ts index 64d225f155..a3839793eb 100644 --- a/server/src/controllers/memory.controller.spec.ts +++ b/server/src/controllers/memory.controller.spec.ts @@ -106,7 +106,11 @@ describe(MemoryController.name, () => { it('should require at least one field', async () => { const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({}); expect(status).toBe(400); - expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }])); + expect(body).toEqual( + errorDto.validationError([ + { path: [], message: 'At least one of the following fields is required: isSaved, seenAt, memoryAt' }, + ]), + ); }); }); diff --git a/server/src/database.ts b/server/src/database.ts index 1770b9d720..100ba451e7 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -304,6 +304,36 @@ export const columns = { 'asset.height', 'asset.isEdited', ], + searchAsset: [ + 'asset.id', + 'asset.updateId', + 'asset.createdAt', + 'asset.updatedAt', + 'asset.deletedAt', + 'asset.status', + 'asset.checksum', + 'asset.checksumAlgorithm', + 'asset.duplicateId', + 'asset.duration', + 'asset.fileCreatedAt', + 'asset.fileModifiedAt', + 'asset.isExternal', + 'asset.isFavorite', + 'asset.isOffline', + 'asset.isEdited', + 'asset.visibility', + 'asset.libraryId', + 'asset.livePhotoVideoId', + 'asset.localDateTime', + 'asset.originalFileName', + 'asset.originalPath', + 'asset.ownerId', + 'asset.stackId', + 'asset.thumbhash', + 'asset.type', + 'asset.width', + 'asset.height', + ], workflowAssetV1: [ 'asset.id', 'asset.ownerId', diff --git a/server/src/decorators.ts b/server/src/decorators.ts index f89de14610..07398b0058 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -108,9 +108,11 @@ export function ChunkedSet(options?: { paramIndex?: number; chunkSize?: number } } const UUID = '00000000-0000-4000-a000-000000000000'; +const UUID_1 = '00000000-0000-4000-a000-000000000001'; export const DummyValue = { UUID, + UUID_1, UUID_SET: new Set([UUID]), PAGINATION: { take: 10, skip: 0 }, EMAIL: 'user@immich.app', diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index ec4d58dae3..7911f92f44 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -3,8 +3,15 @@ import { Place } from 'src/database'; import { HistoryBuilder } from 'src/decorators'; import { AlbumResponseSchema } from 'src/dtos/album.dto'; import { AssetResponseSchema } from 'src/dtos/asset-response.dto'; -import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum'; -import { isoDatetimeToDate, stringToBool } from 'src/validation'; +import { + AssetOrder, + AssetOrderSchema, + AssetTypeSchema, + AssetVisibilitySchema, + SearchOrderField, + SearchOrderFieldSchema, +} from 'src/enum'; +import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation'; import z from 'zod'; const BaseSearchSchema = z.object({ @@ -142,6 +149,176 @@ const SearchSuggestionRequestSchema = z }) .meta({ id: 'SearchSuggestionRequestDto' }); +const IdFilterSchema = nonEmptyPartial({ + eq: z.uuidv4(), + ne: z.uuidv4(), +}).meta({ id: 'IdFilter' }); + +const IdFilterNullableSchema = nonEmptyPartial({ + eq: z.uuidv4().nullable(), + ne: z.uuidv4().nullable(), +}).meta({ id: 'IdFilterNullable' }); + +const IdsFilterSchema = nonEmptyPartial({ + any: z.array(z.uuidv4()).min(1), + all: z.array(z.uuidv4()).min(1), + none: z.array(z.uuidv4()).min(1), +}).meta({ id: 'IdsFilter' }); + +const stringListShape = { + in: z.array(z.string()).min(1), + notIn: z.array(z.string()).min(1), +}; + +const StringFilterSchema = nonEmptyPartial({ + eq: z.string(), + ne: z.string(), + ...stringListShape, +}).meta({ id: 'StringFilter' }); + +const stringNullableShape = { + eq: z.string().nullable(), + ne: z.string().nullable(), + ...stringListShape, +}; + +const StringFilterNullableSchema = nonEmptyPartial(stringNullableShape).meta({ id: 'StringFilterNullable' }); + +const StringPatternFilterSchema = nonEmptyPartial({ + ...stringNullableShape, + like: z.string().min(1), + notLike: z.string().min(1), + startsWith: z.string().min(1), + endsWith: z.string().min(1), +}).meta({ id: 'StringPatternFilter' }); + +const numberRangeShape = { + lt: z.number(), + lte: z.number(), + gt: z.number(), + gte: z.number(), + in: z.array(z.number()).min(1), + notIn: z.array(z.number()).min(1), +}; + +const NumberFilterSchema = nonEmptyPartial({ + eq: z.number(), + ne: z.number(), + ...numberRangeShape, +}).meta({ id: 'NumberFilter' }); + +const NumberFilterNullableSchema = nonEmptyPartial({ + eq: z.number().nullable(), + ne: z.number().nullable(), + ...numberRangeShape, +}).meta({ id: 'NumberFilterNullable' }); + +const dateRangeShape = { + gt: isoDatetimeToDate, + gte: isoDatetimeToDate, + lt: isoDatetimeToDate, + lte: isoDatetimeToDate, +}; + +const DateFilterSchema = nonEmptyPartial({ + eq: isoDatetimeToDate, + ne: isoDatetimeToDate, + ...dateRangeShape, +}).meta({ id: 'DateFilter' }); + +const DateFilterNullableSchema = nonEmptyPartial({ + eq: isoDatetimeToDate.nullable(), + ne: isoDatetimeToDate.nullable(), + ...dateRangeShape, +}).meta({ id: 'DateFilterNullable' }); + +const BoolFilterSchema = z.object({ eq: z.boolean() }).meta({ id: 'BoolFilter' }); + +const enumFilterSchema = (values: z.ZodEnum, id: string) => + nonEmptyPartial({ + eq: values, + ne: values, + in: z.array(values).min(1), + notIn: z.array(values).min(1), + }).meta({ id }); + +const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType'); +const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility'); + +const StringSimilarityFilterSchema = z + .object({ + matches: z.string().min(1), + }) + .meta({ id: 'StringSimilarityFilter' }); + +export const DEFAULT_SEARCH_ORDER = { + field: SearchOrderField.FileCreatedAt, + direction: AssetOrder.Desc, +}; + +export const SearchOrderSchema = z + .object({ + field: SearchOrderFieldSchema.default(DEFAULT_SEARCH_ORDER.field), + direction: AssetOrderSchema.default(DEFAULT_SEARCH_ORDER.direction), + }) + .meta({ id: 'SearchOrder' }); + +const SearchFilterBranchSchema = z + .object({ + id: IdFilterSchema, + libraryId: IdFilterNullableSchema, + type: EnumFilterAssetTypeSchema, + visibility: EnumFilterAssetVisibilitySchema, + isFavorite: BoolFilterSchema, + isMotion: BoolFilterSchema, + isOffline: BoolFilterSchema, + isEncoded: BoolFilterSchema, + hasAlbums: BoolFilterSchema, + hasPeople: BoolFilterSchema, + hasTags: BoolFilterSchema, + city: StringFilterNullableSchema, + state: StringFilterNullableSchema, + country: StringFilterNullableSchema, + make: StringFilterNullableSchema, + model: StringFilterNullableSchema, + lensModel: StringFilterNullableSchema, + description: StringPatternFilterSchema, + originalFileName: StringPatternFilterSchema, + originalPath: StringPatternFilterSchema, + ocr: StringSimilarityFilterSchema, + rating: NumberFilterNullableSchema, + fileSizeInBytes: NumberFilterSchema, + takenAt: DateFilterSchema, + createdAt: DateFilterSchema, + updatedAt: DateFilterSchema, + trashedAt: DateFilterNullableSchema, + personIds: IdsFilterSchema, + tagIds: IdsFilterSchema, + albumIds: IdsFilterSchema, + checksum: StringFilterSchema, + encodedVideoPath: StringFilterSchema, + }) + .partial() + .meta({ id: 'SearchFilterBranch' }); + +export const SearchFilterSchema = SearchFilterBranchSchema.extend({ + or: z.array(SearchFilterBranchSchema).min(1).optional(), +}).meta({ id: 'SearchFilter' }); + +export type IdFilter = z.infer; +export type IdFilterNullable = z.infer; +export type IdsFilter = z.infer; +export type StringFilter = z.infer; +export type StringFilterNullable = z.infer; +export type StringPatternFilter = z.infer; +export type NumberFilter = z.infer; +export type NumberFilterNullable = z.infer; +export type DateFilter = z.infer; +export type DateFilterNullable = z.infer; +export type SearchOrder = z.infer; +export type SearchFilter = z.infer; +export type SearchFilterBranch = z.infer; + export class RandomSearchDto extends createZodDto(RandomSearchSchema) {} export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {} export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {} diff --git a/server/src/enum.ts b/server/src/enum.ts index 0996abe6fc..0d29244e09 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1234,3 +1234,12 @@ export enum CalendarHeatmapType { Upload = 'Upload', Taken = 'Taken', } + +export enum SearchOrderField { + FileCreatedAt = 'fileCreatedAt', + LocalDateTime = 'localDateTime', + FileSizeInBytes = 'fileSizeInBytes', + Rating = 'rating', +} + +export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' }); diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 25b8566375..efd7236bb5 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -2,7 +2,34 @@ -- SearchRepository.searchMetadata select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -13,7 +40,8 @@ where and "asset"."isFavorite" = $4 and "asset"."deletedAt" is null order by - "asset"."fileCreatedAt" desc + "asset"."fileCreatedAt" desc, + "asset"."id" desc limit $5 offset @@ -34,7 +62,34 @@ where -- SearchRepository.searchRandom select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -51,7 +106,34 @@ limit -- SearchRepository.searchLargeAssets select - "asset".*, + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height", to_json("asset_exif") as "exifInfo" from "asset" @@ -73,7 +155,34 @@ begin set local vchordrq.probes = 1 select - "asset".* + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" from "asset" inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" @@ -85,7 +194,8 @@ where and "asset"."isFavorite" = $4 and "asset"."deletedAt" is null order by - smart_search.embedding <=> $5 + smart_search.embedding <=> $5, + "asset"."id" asc limit $6 offset @@ -203,7 +313,34 @@ with recursive ) ) select - "asset".*, + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height", to_jsonb("asset_exif") as "exifInfo" from "asset" @@ -276,3 +413,1071 @@ where and "deletedAt" is null and "lensModel" is not null and "lensModel" != $3 + +-- SearchRepository.searchMetadataV3 (baseline) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (empty) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + true +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $1 + +-- SearchRepository.searchMetadataV3 (or-exif-only) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset_exif"."city" = $2 +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-eq-null) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset_exif"."city" is null +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (string-pattern-like) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-notLike) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-pattern-startsWith) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%') +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (string-similarity-ocr) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "ocr_search" + where + "ocr_search"."assetId" = "asset"."id" + and f_unaccent (ocr_search.text) %>> f_unaccent ($2) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-any) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-all) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + "asset_face"."assetId" + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $2 + and "asset_face"."personId" = any ($3::uuid[]) + group by + "asset_face"."assetId" + having + count(distinct "asset_face"."personId") = $4 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (ids-all-single) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-none) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and not exists ( + select + from + "tag_asset" + inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" + where + "tag_asset"."assetId" = "asset"."id" + and "tag_closure"."id_ancestor" = any ($2::uuid[]) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (ids-tags-all) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + "tag_asset"."assetId" + from + "tag_asset" + inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant" + where + "tag_asset"."assetId" = "asset"."id" + and "tag_closure"."id_ancestor" = any ($2::uuid[]) + group by + "tag_asset"."assetId" + having + count(distinct "tag_closure"."id_ancestor") = $3 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (has-albums-false) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (is-encoded) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and exists ( + select + from + "asset_file" + where + "asset_file"."assetId" = "asset"."id" + and "asset_file"."type" = $2 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (number-range) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset_exif"."fileSizeInByte" <= $2 + and "asset_exif"."fileSizeInByte" >= $3 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (date-eq) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and "asset"."fileCreatedAt" = $2 +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $3 + +-- SearchRepository.searchMetadataV3 (date-range) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."fileCreatedAt" < $2 + and "asset"."fileCreatedAt" >= $3 + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $4 + +-- SearchRepository.searchMetadataV3 (order-fileSize-noExif) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset_exif"."fileSizeInByte" desc nulls last, + "asset"."id" desc +limit + $2 + +-- SearchRepository.searchMetadataV3 (order-rating-withExif) +select + to_json("asset_exif") as "exifInfo", + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true +order by + "asset_exif"."rating" asc nulls last, + "asset"."id" asc +limit + $2 + +-- SearchRepository.searchMetadataV3 (or-branches) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."isFavorite" = $2 + or exists ( + select + from + "asset_face" + where + "asset_face"."assetId" = "asset"."id" + and "asset_face"."deletedAt" is null + and "asset_face"."isVisible" = $3 + and "asset_face"."personId" = any ($4::uuid[]) + ) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $5 + +-- SearchRepository.searchMetadataV3 (or-with-top-level) +select + "asset"."id", + "asset"."updateId", + "asset"."createdAt", + "asset"."updatedAt", + "asset"."deletedAt", + "asset"."status", + "asset"."checksum", + "asset"."checksumAlgorithm", + "asset"."duplicateId", + "asset"."duration", + "asset"."fileCreatedAt", + "asset"."fileModifiedAt", + "asset"."isExternal", + "asset"."isFavorite", + "asset"."isOffline", + "asset"."isEdited", + "asset"."visibility", + "asset"."libraryId", + "asset"."livePhotoVideoId", + "asset"."localDateTime", + "asset"."originalFileName", + "asset"."originalPath", + "asset"."ownerId", + "asset"."stackId", + "asset"."thumbhash", + "asset"."type", + "asset"."width", + "asset"."height" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."fileCreatedAt" < $2 + and "asset"."fileCreatedAt" >= $3 + and ( + "asset"."isFavorite" = $4 + or exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + and "album_asset"."albumId" = any ($5::uuid[]) + ) + ) + ) +order by + "asset"."fileCreatedAt" desc, + "asset"."id" desc +limit + $6 + +-- SearchRepository.searchStatisticsV3 (baseline) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and true + +-- SearchRepository.searchStatisticsV3 (with-filter) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset_exif"."fileSizeInByte" >= $2 + and "asset"."fileCreatedAt" < $3 + and "asset"."fileCreatedAt" >= $4 + ) + +-- SearchRepository.searchStatisticsV3 (with-or) +select + count(*) as "total" +from + "asset" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."ownerId" = any ($1::uuid[]) + and ( + "asset"."isFavorite" = $2 + or not exists ( + select + from + "album_asset" + where + "album_asset"."assetId" = "asset"."id" + ) + ) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 7faa19f7cd..70ba09ef2c 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,12 +1,23 @@ import { Injectable } from '@nestjs/common'; import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; +import { MapAsset } from 'src/dtos/asset-response.dto'; +import { SearchFilter, SearchOrder } from 'src/dtos/search.dto'; import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database'; +import { + anyUuid, + searchAssetBuilder, + searchAssetBuilderLegacy, + searchMetadataV3Examples, + searchStatisticsV3Examples, + withExifInner, + withSearchOrder, +} from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import z from 'zod'; @@ -122,6 +133,21 @@ export type AssetSearchOptions = Omit & export type AssetSearchBuilderOptions = Omit; +export interface AssetSearchBuilderV3Options { + filter?: SearchFilter; + /** Server-derived ownership scope. Never client-controlled. */ + userIds?: string[]; + withExif?: boolean; + withFaces?: boolean; + withPeople?: boolean; + withStacked?: boolean; + order?: SearchOrder; +} + +export interface AssetSearchPaginationV3Options { + size: number; +} + export type SmartSearchOptions = SearchDateOptions & SearchEmbeddingOptions & SearchExifOptions & @@ -196,9 +222,10 @@ export class SearchRepository { }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - const items = await searchAssetBuilder(this.db, options) - .selectAll('asset') + const items = await searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .orderBy('asset.fileCreatedAt', orderDirection) + .orderBy('asset.id', orderDirection) .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); @@ -217,7 +244,7 @@ export class SearchRepository { ], }) searchStatistics(options: AssetSearchOptions) { - return searchAssetBuilder(this.db, options) + return searchAssetBuilderLegacy(this.db, options) .select((qb) => qb.fn.countAll().as('total')) .executeTakeFirstOrThrow(); } @@ -235,8 +262,8 @@ export class SearchRepository { ], }) async searchRandom(size: number, options: AssetSearchOptions) { - return searchAssetBuilder(this.db, options) - .selectAll('asset') + return searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .orderBy(sql`random()`) .limit(size) .execute(); @@ -256,8 +283,8 @@ export class SearchRepository { }) searchLargeAssets(size: number, options: LargeAssetSearchOptions) { const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; - return searchAssetBuilder(this.db, options) - .selectAll('asset') + return searchAssetBuilderLegacy(this.db, options) + .select(columns.searchAsset) .$call(withExifInner) .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) .orderBy('asset_exif.fileSizeInByte', orderDirection) @@ -285,10 +312,11 @@ export class SearchRepository { return this.db.transaction().execute(async (trx) => { await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx); - const items = await searchAssetBuilder(trx, options) - .selectAll('asset') + const items = await searchAssetBuilderLegacy(trx, options) + .select(columns.searchAsset) .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') .orderBy(sql`smart_search.embedding <=> ${options.embedding}`) + .orderBy('asset.id', 'asc') .limit(pagination.size + 1) .offset((pagination.page - 1) * pagination.size) .execute(); @@ -417,7 +445,7 @@ export class SearchRepository { .selectFrom('asset') .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') .innerJoin('cte', 'asset.id', 'cte.assetId') - .selectAll('asset') + .select(columns.searchAsset) .select((eb) => eb .fn('to_jsonb', [eb.table('asset_exif')]) @@ -490,6 +518,24 @@ export class SearchRepository { return res.map((row) => row.lensModel!); } + @GenerateSql(...searchMetadataV3Examples) + searchMetadataV3( + pagination: AssetSearchPaginationV3Options, + options: AssetSearchBuilderV3Options, + ): Promise { + return withSearchOrder(searchAssetBuilder(this.db, options), options.order) + .select(columns.searchAsset) + .limit(pagination.size) + .execute(); + } + + @GenerateSql(...searchStatisticsV3Examples) + searchStatisticsV3(options: AssetSearchBuilderV3Options) { + return searchAssetBuilder(this.db, options) + .select((qb) => qb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + } + private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) { return this.db .selectFrom('asset_exif') diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index ffd5a603e4..0f4d8775b6 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -7,21 +7,42 @@ import { Kysely, KyselyConfig, NotNull, + OperandValueExpression, + ReferenceExpression, Selectable, SelectQueryBuilder, ShallowDehydrateObject, sql, + SqlBool, } from 'kysely'; import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; +import { DummyValue, GenerateSqlQueries } from 'src/decorators'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum'; -import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; +import { + DEFAULT_SEARCH_ORDER, + IdsFilter, + SearchFilterBranch, + SearchOrder, + StringFilter, + StringPatternFilter, +} from 'src/dtos/search.dto'; +import { + AssetFileType, + AssetOrder, + AssetOrderBy, + AssetVisibility, + DatabaseExtension, + ExifOrientation, + SearchOrderField, +} from 'src/enum'; +import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types'; +import { fromChecksum } from 'src/utils/request'; export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => { return { @@ -54,6 +75,8 @@ export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyCon }; }; +const uniqueIds = (ids: string[]) => [...new Set(ids)]; + export const asUuid = (id: string | Expression) => sql`${id}::uuid`; export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; @@ -85,16 +108,15 @@ export function withDefaultVisibility(qb: SelectQueryBuilder) return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]); } +const selectExifInfo = (eb: AssetExpressionBuilder) => + eb.fn + .toJson(eb.table('asset_exif')) + .$castTo> | null>() + .as('exifInfo'); + // TODO come up with a better query that only selects the fields we need export function withExif(qb: SelectQueryBuilder) { - return qb - .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => - eb.fn - .toJson(eb.table('asset_exif')) - .$castTo> | null>() - .as('exifInfo'), - ); + return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo); } export function withExifInner(qb: SelectQueryBuilder) { @@ -373,7 +395,7 @@ export function withEdits(eb: ExpressionBuilder): AliasedEditAction const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ -export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { +export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearchBuilderOptions) { options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); return kysely @@ -493,6 +515,458 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); } +type AssetExpressionBuilder = ExpressionBuilder; + +const albumAssets = (eb: AssetExpressionBuilder) => + eb.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id'); + +const visibleFaces = (eb: AssetExpressionBuilder) => + eb + .selectFrom('asset_face') + .whereRef('asset_face.assetId', '=', 'asset.id') + .where('asset_face.deletedAt', 'is', null) + .where('asset_face.isVisible', '=', true); + +const tagAssets = (eb: AssetExpressionBuilder) => + eb.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id'); + +// shared any/all/none mechanics; `matchesAll` only receives deduplicated multi-id lists, +// so its `count(distinct id) = ids.length` check stays satisfiable +function idsPredicates( + eb: AssetExpressionBuilder, + { any, all, none }: IdsFilter = {}, + ops: { + matchesAny: (ids: string[]) => Expression; + matchesAll: (ids: string[]) => Expression; + }, +) { + const predicates: Expression[] = []; + if (any) { + predicates.push(ops.matchesAny(any)); + } + if (all) { + const ids = uniqueIds(all); + predicates.push(ids.length === 1 ? ops.matchesAny(ids) : ops.matchesAll(ids)); + } + if (none) { + predicates.push(eb.not(ops.matchesAny(none))); + } + return predicates; +} + +function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => albumAssets(eb).where('album_asset.albumId', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('album_asset.assetId') + .groupBy('album_asset.assetId') + .having((eb) => eb.fn.count('album_asset.albumId').distinct(), '=', ids.length), + ), + }); +} + +function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('asset_face.assetId') + .groupBy('asset_face.assetId') + .having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length), + ), + }); +} + +function tagIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { + const matching = (ids: string[]) => + tagAssets(eb) + .innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant') + .where('tag_closure.id_ancestor', '=', anyUuid(ids)); + return idsPredicates(eb, filter, { + matchesAny: (ids) => eb.exists(matching(ids)), + matchesAll: (ids) => + eb.exists( + matching(ids) + .select('tag_asset.assetId') + .groupBy('tag_asset.assetId') + .having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '=', ids.length), + ), + }); +} + +type ComparisonFilter = { + eq?: T | null; + ne?: T | null; + lt?: T; + lte?: T; + gt?: T; + gte?: T; + in?: T[]; + notIn?: T[]; +}; + +// one operator dispatch for every filter shape; the DTO schemas constrain which +// operators (and null literals) each filter can actually carry +function comparisonPredicates>( + eb: ExpressionBuilder, + column: RE, + filter: ComparisonFilter> = {}, +) { + const predicates: Expression[] = []; + if (filter.eq !== undefined) { + predicates.push(filter.eq === null ? eb(column, 'is', null) : eb(column, '=', filter.eq)); + } + if (filter.ne !== undefined) { + predicates.push(filter.ne === null ? eb(column, 'is not', null) : eb(column, '!=', filter.ne)); + } + if (filter.lt !== undefined) { + predicates.push(eb(column, '<', filter.lt)); + } + if (filter.lte !== undefined) { + predicates.push(eb(column, '<=', filter.lte)); + } + if (filter.gt !== undefined) { + predicates.push(eb(column, '>', filter.gt)); + } + if (filter.gte !== undefined) { + predicates.push(eb(column, '>=', filter.gte)); + } + if (filter.in !== undefined) { + predicates.push(eb(column, 'in', filter.in)); + } + if (filter.notIn !== undefined) { + predicates.push(eb(column, 'not in', filter.notIn)); + } + return predicates; +} + +type StringColumn = + | 'asset_exif.city' + | 'asset_exif.state' + | 'asset_exif.country' + | 'asset_exif.make' + | 'asset_exif.model' + | 'asset_exif.lensModel' + | 'asset_exif.description' + | 'asset.originalFileName' + | 'asset.originalPath'; + +function stringPatternPredicates(eb: AssetExpressionBuilder, column: StringColumn, filter: StringPatternFilter = {}) { + const ref = sql.ref(column); + const predicates = comparisonPredicates(eb, column, filter); + if (filter.like !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.like}) || '%')`); + } + if (filter.notLike !== undefined) { + predicates.push(sql`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${filter.notLike}) || '%')`); + } + if (filter.startsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike (f_unaccent(${filter.startsWith}) || '%')`); + } + if (filter.endsWith !== undefined) { + predicates.push(sql`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.endsWith}))`); + } + return predicates; +} + +function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter = {}) { + return comparisonPredicates(eb, 'asset.checksum', { + eq: filter.eq === undefined ? undefined : fromChecksum(filter.eq), + ne: filter.ne === undefined ? undefined : fromChecksum(filter.ne), + in: filter.in?.map((checksum) => fromChecksum(checksum)), + notIn: filter.notIn?.map((checksum) => fromChecksum(checksum)), + }); +} + +const encodedVideoFiles = (eb: AssetExpressionBuilder) => + eb + .selectFrom('asset_file') + .whereRef('asset_file.assetId', '=', 'asset.id') + .where('asset_file.type', '=', AssetFileType.EncodedVideo); + +function existsPredicates( + eb: AssetExpressionBuilder, + filter: { eq: boolean } | undefined, + subquery: () => Expression, +): Expression[] { + if (!filter) { + return []; + } + const exists = eb.exists(subquery()); + return [filter.eq ? exists : eb.not(exists)]; +} + +// predicates are collected as expressions rather than chained `where` calls so the same +// helpers can build each `or` branch, which must compose into eb.and/eb.or +function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch) { + const { encodedVideoPath } = branch; + return [ + ...comparisonPredicates(eb, 'asset.id', branch.id), + ...comparisonPredicates(eb, 'asset.libraryId', branch.libraryId), + ...comparisonPredicates(eb, 'asset.type', branch.type), + ...comparisonPredicates(eb, 'asset.visibility', branch.visibility), + ...(branch.isFavorite ? [eb('asset.isFavorite', '=', branch.isFavorite.eq)] : []), + ...(branch.isOffline ? [eb('asset.isOffline', '=', branch.isOffline.eq)] : []), + ...(branch.isMotion ? [eb('asset.livePhotoVideoId', branch.isMotion.eq ? 'is not' : 'is', null)] : []), + ...existsPredicates(eb, branch.isEncoded, () => encodedVideoFiles(eb)), + ...existsPredicates(eb, branch.hasAlbums, () => albumAssets(eb)), + ...existsPredicates(eb, branch.hasPeople, () => visibleFaces(eb)), + ...existsPredicates(eb, branch.hasTags, () => tagAssets(eb)), + ...comparisonPredicates(eb, 'asset_exif.city', branch.city), + ...comparisonPredicates(eb, 'asset_exif.state', branch.state), + ...comparisonPredicates(eb, 'asset_exif.country', branch.country), + ...comparisonPredicates(eb, 'asset_exif.make', branch.make), + ...comparisonPredicates(eb, 'asset_exif.model', branch.model), + ...comparisonPredicates(eb, 'asset_exif.lensModel', branch.lensModel), + ...stringPatternPredicates(eb, 'asset_exif.description', branch.description), + ...stringPatternPredicates(eb, 'asset.originalFileName', branch.originalFileName), + ...stringPatternPredicates(eb, 'asset.originalPath', branch.originalPath), + ...(branch.ocr + ? [ + eb.exists( + eb + .selectFrom('ocr_search') + .whereRef('ocr_search.assetId', '=', 'asset.id') + .where( + sql`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(branch.ocr.matches).join(' ')})`, + ), + ), + ] + : []), + ...comparisonPredicates(eb, 'asset_exif.rating', branch.rating), + ...comparisonPredicates(eb, 'asset_exif.fileSizeInByte', branch.fileSizeInBytes), + ...comparisonPredicates(eb, 'asset.fileCreatedAt', branch.takenAt), + ...comparisonPredicates(eb, 'asset.createdAt', branch.createdAt), + ...comparisonPredicates(eb, 'asset.updatedAt', branch.updatedAt), + ...comparisonPredicates(eb, 'asset.deletedAt', branch.trashedAt), + ...albumIdsPredicates(eb, branch.albumIds), + ...personIdsPredicates(eb, branch.personIds), + ...tagIdsPredicates(eb, branch.tagIds), + ...checksumPredicates(eb, branch.checksum), + ...(encodedVideoPath + ? [ + eb.exists( + encodedVideoFiles(eb) + .where('asset_file.isEdited', '=', false) + .where((eb) => eb.and(comparisonPredicates(eb, 'asset_file.path', encodedVideoPath))), + ), + ] + : []), + ]; +} + +// ordering is deliberately left to the caller so aggregate-only consumers (counts, stats) +// can compose the same filters without stripping an order by +export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderV3Options) { + const filter = options.filter ?? {}; + + return ( + kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('asset') + // postgres eliminates the left join when no exif column is referenced, so unused joins are free + .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') + .$if(!!options.withExif, (qb) => qb.select(selectExifInfo)) + .$if(!!options.userIds && options.userIds.length > 0, (qb) => + qb.where('asset.ownerId', '=', anyUuid(options.userIds!)), + ) + .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) + .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) + .where((eb) => { + const predicates = branchPredicates(eb, filter); + if (filter.or && filter.or.length > 0) { + predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch))))); + } + return predicates.length > 0 ? eb.and(predicates) : eb.lit(true); + }) + ); +} + +const searchOrderColumns = { + [SearchOrderField.FileCreatedAt]: { column: 'asset.fileCreatedAt', nullable: false }, + [SearchOrderField.LocalDateTime]: { column: 'asset.localDateTime', nullable: false }, + [SearchOrderField.FileSizeInBytes]: { column: 'asset_exif.fileSizeInByte', nullable: true }, + [SearchOrderField.Rating]: { column: 'asset_exif.rating', nullable: true }, +} as const; + +export function withSearchOrder(qb: ReturnType, order?: SearchOrder) { + const { field, direction } = order ?? DEFAULT_SEARCH_ORDER; + const { column, nullable } = searchOrderColumns[field]; + return ( + qb + .orderBy(column, (ob) => { + const ordered = direction === AssetOrder.Asc ? ob.asc() : ob.desc(); + // nulls last: assets without an asset_exif row would otherwise lead descending results + return nullable ? ordered.nullsLast() : ordered; + }) + // id tie-break for deterministic pagination + .orderBy('asset.id', direction) + ); +} + +export const searchMetadataV3Examples: GenerateSqlQueries[] = [ + { name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] }, + { name: 'empty', params: [{ size: 100 }, {}] }, + { + name: 'or-exif-only', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }], + }, + { + name: 'string-eq-null', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }], + }, + { + name: 'string-pattern-like', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }], + }, + { + name: 'string-pattern-notLike', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }], + }, + { + name: 'string-pattern-startsWith', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } }, + ], + }, + { + name: 'string-similarity-ocr', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }], + }, + { + name: 'ids-any', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }], + }, + { + name: 'ids-all', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + ], + }, + { + name: 'ids-all-single', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }], + }, + { + name: 'ids-none', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }], + }, + { + name: 'ids-tags-all', + params: [ + { size: 100 }, + { userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } }, + ], + }, + { + name: 'has-albums-false', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }], + }, + { + name: 'is-encoded', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }], + }, + { + name: 'number-range', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }], + }, + { + name: 'date-eq', + params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }], + }, + { + name: 'date-range', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } }, + }, + ], + }, + { + name: 'order-fileSize-noExif', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc }, + withExif: false, + }, + ], + }, + { + name: 'order-rating-withExif', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc }, + withExif: true, + }, + ], + }, + { + name: 'or-branches', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { + or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }], + }, + }, + ], + }, + { + name: 'or-with-top-level', + params: [ + { size: 100 }, + { + userIds: [DummyValue.UUID], + filter: { + takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, + or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }], + }, + }, + ], + }, +]; + +export const searchStatisticsV3Examples: GenerateSqlQueries[] = [ + { name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] }, + { + name: 'with-filter', + params: [ + { + userIds: [DummyValue.UUID], + filter: { + takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE }, + fileSizeInBytes: { gte: 100 }, + }, + }, + ], + }, + { + name: 'with-or', + params: [ + { + userIds: [DummyValue.UUID], + filter: { + or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }], + }, + }, + ], + }, +]; + export type ReindexVectorIndexOptions = { indexName: string; lists?: number }; type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions; diff --git a/server/src/validation.ts b/server/src/validation.ts index 7188de1bed..f8de2a68ff 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -42,7 +42,7 @@ export function nonEmptyPartial(shape: T) { .object(shape) .partial() .refine((data) => Object.values(data as Record).some((value) => value !== undefined), { - message: 'At least one field must be provided', + message: `At least one of the following fields is required: ${Object.keys(shape).join(', ')}`, }); } From 3bd580e37d665f3c7affdc8ffc3116bc5735fc24 Mon Sep 17 00:00:00 2001 From: okxint <130782884+okxint@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:36:59 +0530 Subject: [PATCH 044/163] fix(web): restore correct back route when opening person asset via direct URL (#30129) --- .../[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 1ef13c8504..3fe707c6df 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -95,6 +95,8 @@ const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE); if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) { previousRoute = getPreviousRoute; + } else if ($page.params.assetId) { + previousRoute = Route.viewPerson(data.person); } if (action == 'merge') { viewMode = PersonPageViewMode.MERGE_PEOPLE; From cbb565b7b7acb4c5360e856d1557f4a987cd942e Mon Sep 17 00:00:00 2001 From: NOBOIKE <53275945+noboike@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:22:45 +0300 Subject: [PATCH 045/163] fix(web): mirror asset viewer navigation icons in RTL (#30151) --- .../components/asset-viewer/actions/NextAssetAction.svelte | 5 +++-- .../asset-viewer/actions/PreviousAssetAction.svelte | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte b/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte index 6389cf95e0..429c45cf52 100644 --- a/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte +++ b/web/src/lib/components/asset-viewer/actions/NextAssetAction.svelte @@ -1,9 +1,10 @@ - +

{#if loading} diff --git a/web/src/lib/modals/AssetAddToAlbumModal.svelte b/web/src/lib/modals/AssetAddToAlbumModal.svelte index b35c125d08..7259dd8245 100644 --- a/web/src/lib/modals/AssetAddToAlbumModal.svelte +++ b/web/src/lib/modals/AssetAddToAlbumModal.svelte @@ -24,4 +24,4 @@ }; - + From 4d9a27691ee00de3e519a47e1906f6e906bfbde4 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:53:12 +0530 Subject: [PATCH 140/163] fix: action provider overrides (#30480) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/presentation/actions/action.dart | 4 +++- .../presentation/actions/archive.action.dart | 2 +- .../presentation/actions/delete.action.dart | 4 ++-- .../presentation/actions/download.action.dart | 2 +- .../actions/edit_asset.action.dart | 2 +- .../actions/edit_datetime.action.dart | 2 +- .../actions/edit_location.action.dart | 2 +- .../presentation/actions/favorite.action.dart | 2 +- .../lib/presentation/actions/lock.action.dart | 2 +- .../actions/remove_from_album.action.dart | 2 +- .../presentation/actions/restore.action.dart | 2 +- .../actions/set_album_cover.action.dart | 2 +- .../presentation/actions/share.action.dart | 2 +- .../actions/share_link.action.dart | 2 +- .../presentation/actions/stack.action.dart | 2 +- .../lib/presentation/actions/tag.action.dart | 2 +- .../presentation/actions/upload.action.dart | 2 +- .../presentation/presentation_context.dart | 19 +++++++++++-------- 18 files changed, 31 insertions(+), 26 deletions(-) diff --git a/mobile/lib/presentation/actions/action.dart b/mobile/lib/presentation/actions/action.dart index 072c2524be..d880b7ddf7 100644 --- a/mobile/lib/presentation/actions/action.dart +++ b/mobile/lib/presentation/actions/action.dart @@ -33,6 +33,7 @@ final assetsActionProvider = Provider.family.autoDispose, null => const {}, }, }), + dependencies: [multiSelectProvider], ); final clearSelectionProvider = Provider.family.autoDispose((ref, source) { @@ -41,10 +42,11 @@ final clearSelectionProvider = Provider.family.autoDispose, ActionSource>( (ref, source) => ref.watch(assetsActionProvider(source)).owned(ref.watch(authUserProvider).id), + dependencies: [assetsActionProvider], ); abstract class AssetActionBuilder extends ActionBuilder { diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart index f04611c4c0..f8e4a1e038 100644 --- a/mobile/lib/presentation/actions/archive.action.dart +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -22,7 +22,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, .map((asset) => asset.id) .toList(growable: false); return assetIds.isEmpty ? null : (shouldArchive: shouldArchive, assetIds: assetIds); -}); +}, dependencies: [ownedAssetsActionProvider]); class ArchiveAction extends AssetActionBuilder { const ArchiveAction({required super.source}); diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index 0fe297bd03..31d01b32ef 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -40,7 +40,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); -}); +}, dependencies: [assetsActionProvider]); class DeleteAction extends AssetActionBuilder { const DeleteAction({required super.source}); @@ -149,7 +149,7 @@ final _cleanupStateProvider = Provider.family.autoDispose?, ActionS final assets = ref.watch(assetsActionProvider(source)); final assetIds = assets.backedUp().map((asset) => asset.localId).nonNulls.toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [assetsActionProvider]); class CleanupLocalAction extends AssetActionBuilder { const CleanupLocalAction({required super.source}); diff --git a/mobile/lib/presentation/actions/download.action.dart b/mobile/lib/presentation/actions/download.action.dart index 1303fa6b03..60a1395bcd 100644 --- a/mobile/lib/presentation/actions/download.action.dart +++ b/mobile/lib/presentation/actions/download.action.dart @@ -14,7 +14,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSou final assets = ref.watch(assetsActionProvider(source)); final remote = assets.remote().toList(growable: false); return remote.isEmpty ? null : remote; -}); +}, dependencies: [assetsActionProvider]); class DownloadAction extends AssetActionBuilder { const DownloadAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_asset.action.dart b/mobile/lib/presentation/actions/edit_asset.action.dart index d0a7e73122..9b11032df9 100644 --- a/mobile/lib/presentation/actions/edit_asset.action.dart +++ b/mobile/lib/presentation/actions/edit_asset.action.dart @@ -28,7 +28,7 @@ final _stateProvider = Provider.family.autoDispose(( final assets = ref.watch(ownedAssetsActionProvider(source)); return assets.where((asset) => asset.isEditable).singleOrNull; -}); +}, dependencies: [ownedAssetsActionProvider]); class EditAssetAction extends AssetActionBuilder { const EditAssetAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_datetime.action.dart b/mobile/lib/presentation/actions/edit_datetime.action.dart index a3c825c4db..31de23d1a7 100644 --- a/mobile/lib/presentation/actions/edit_datetime.action.dart +++ b/mobile/lib/presentation/actions/edit_datetime.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, } return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull); -}); +}, dependencies: [ownedAssetsActionProvider]); class EditDateTimeAction extends AssetActionBuilder { const EditDateTimeAction({required super.source}); diff --git a/mobile/lib/presentation/actions/edit_location.action.dart b/mobile/lib/presentation/actions/edit_location.action.dart index f83a98099c..5ce74a0f40 100644 --- a/mobile/lib/presentation/actions/edit_location.action.dart +++ b/mobile/lib/presentation/actions/edit_location.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, } return (assetIds: assets.map((asset) => asset.id).toList(growable: false), origin: assets.singleOrNull); -}); +}, dependencies: [ownedAssetsActionProvider]); class EditLocationAction extends AssetActionBuilder { const EditLocationAction({required super.source}); diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 17c841b745..402d2f3833 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -18,7 +18,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final shouldFavorite = assets.favorite(isFavorite: false).isNotEmpty; final assetIds = assets.favorite(isFavorite: !shouldFavorite).map((asset) => asset.id).toList(growable: false); return (shouldFavorite: shouldFavorite, assetIds: assetIds); -}); +}, dependencies: [ownedAssetsActionProvider]); class FavoriteAction extends AssetActionBuilder { const FavoriteAction({required super.source}); diff --git a/mobile/lib/presentation/actions/lock.action.dart b/mobile/lib/presentation/actions/lock.action.dart index b7fd01ad18..3d090b712d 100644 --- a/mobile/lib/presentation/actions/lock.action.dart +++ b/mobile/lib/presentation/actions/lock.action.dart @@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, // Only locking has an on-device copy to clean up; unlocking leaves the device alone. localIds: shouldLock ? targets.map((asset) => asset.localId).nonNulls.toList(growable: false) : const [], ); -}); +}, dependencies: [ownedAssetsActionProvider]); class LockAction extends AssetActionBuilder { const LockAction({required super.source}); diff --git a/mobile/lib/presentation/actions/remove_from_album.action.dart b/mobile/lib/presentation/actions/remove_from_album.action.dart index 3d648a9cfd..9e2d9d582f 100644 --- a/mobile/lib/presentation/actions/remove_from_album.action.dart +++ b/mobile/lib/presentation/actions/remove_from_album.action.dart @@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(assetsActionProvider(source)); final assetIds = assets.remote().map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [assetsActionProvider]); class RemoveFromAlbumAction extends AssetActionBuilder { final String albumId; diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index 0a2f34abf8..0a1b707b39 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -11,7 +11,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(ownedAssetsActionProvider(source)); final assetIds = assets.trashed().map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [ownedAssetsActionProvider]); class RestoreAction extends AssetActionBuilder { const RestoreAction({required super.source}); diff --git a/mobile/lib/presentation/actions/set_album_cover.action.dart b/mobile/lib/presentation/actions/set_album_cover.action.dart index 0c16c9a9db..d5ad367eed 100644 --- a/mobile/lib/presentation/actions/set_album_cover.action.dart +++ b/mobile/lib/presentation/actions/set_album_cover.action.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/utils/error_handler.dart'; final _stateProvider = Provider.family.autoDispose((ref, source) { final assets = ref.watch(assetsActionProvider(source)); return assets.remote().map((asset) => asset.id).singleOrNull; -}); +}, dependencies: [assetsActionProvider]); class SetAlbumCoverAction extends AssetActionBuilder { final String albumId; diff --git a/mobile/lib/presentation/actions/share.action.dart b/mobile/lib/presentation/actions/share.action.dart index 4c36493265..3b8a6318de 100644 --- a/mobile/lib/presentation/actions/share.action.dart +++ b/mobile/lib/presentation/actions/share.action.dart @@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSourc final assets = ref.watch(assetsActionProvider(source)); final shareable = assets.toList(growable: false); return shareable.isEmpty ? null : shareable; -}); +}, dependencies: [assetsActionProvider]); class ShareAction extends AssetActionBuilder { const ShareAction({required super.source}); diff --git a/mobile/lib/presentation/actions/share_link.action.dart b/mobile/lib/presentation/actions/share_link.action.dart index 1966dd8811..4285105ed3 100644 --- a/mobile/lib/presentation/actions/share_link.action.dart +++ b/mobile/lib/presentation/actions/share_link.action.dart @@ -12,7 +12,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(assetsActionProvider(source)); final remoteIds = assets.remote().map((asset) => asset.id).toList(growable: false); return remoteIds.isEmpty ? null : remoteIds; -}); +}, dependencies: [assetsActionProvider]); class ShareLinkAction extends AssetActionBuilder { const ShareLinkAction({required super.source}); diff --git a/mobile/lib/presentation/actions/stack.action.dart b/mobile/lib/presentation/actions/stack.action.dart index 9697dc02be..5ab978a7e4 100644 --- a/mobile/lib/presentation/actions/stack.action.dart +++ b/mobile/lib/presentation/actions/stack.action.dart @@ -23,7 +23,7 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, assetIds: assets.map((asset) => asset.id).toList(growable: false), stackIds: assets.map((asset) => asset.stackId).nonNulls.toList(growable: false), ); -}); +}, dependencies: [ownedAssetsActionProvider]); class StackAction extends AssetActionBuilder { const StackAction({required super.source}); diff --git a/mobile/lib/presentation/actions/tag.action.dart b/mobile/lib/presentation/actions/tag.action.dart index 749b23d150..978648708f 100644 --- a/mobile/lib/presentation/actions/tag.action.dart +++ b/mobile/lib/presentation/actions/tag.action.dart @@ -21,7 +21,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSource>( final assets = ref.watch(ownedAssetsActionProvider(source)); final assetIds = assets.map((asset) => asset.id).toList(growable: false); return assetIds.isEmpty ? null : assetIds; -}); +}, dependencies: [ownedAssetsActionProvider]); class TagAction extends AssetActionBuilder { const TagAction({required super.source}); diff --git a/mobile/lib/presentation/actions/upload.action.dart b/mobile/lib/presentation/actions/upload.action.dart index ceb35c8786..e52659beb7 100644 --- a/mobile/lib/presentation/actions/upload.action.dart +++ b/mobile/lib/presentation/actions/upload.action.dart @@ -16,7 +16,7 @@ final _stateProvider = Provider.family.autoDispose?, ActionSour final assets = ref.watch(assetsActionProvider(source)); final local = assets.backedUp(isBackedUp: false).local().toList(growable: false); return local.isEmpty ? null : local; -}); +}, dependencies: [assetsActionProvider]); class UploadAction extends AssetActionBuilder { final bool showProgress; diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 5b23890ef8..36ae8d087d 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -101,15 +101,18 @@ extension PumpPresentationWidget on WidgetTester { useFallbackTranslations: true, assetLoader: const CodegenLoader(), child: ProviderScope( - overrides: [...context.overrides, ...overrides], + overrides: context.overrides, child: Builder( - builder: (context) => MaterialApp( - debugShowCheckedModeBanner: false, - scaffoldMessengerKey: scaffoldMessengerKey, - localizationsDelegates: context.localizationDelegates, - supportedLocales: context.supportedLocales, - locale: context.locale, - home: Scaffold(body: widget), + builder: (context) => ProviderScope( + overrides: overrides, + child: MaterialApp( + debugShowCheckedModeBanner: false, + scaffoldMessengerKey: scaffoldMessengerKey, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + locale: context.locale, + home: Scaffold(body: widget), + ), ), ), ), From e5c3bdad17da1c70bc59d2d09398f8b35d820746 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Mon, 3 Aug 2026 20:23:29 +0600 Subject: [PATCH 141/163] fix(mobile): sync stack changes from the websocket (#30479) --- mobile/lib/providers/websocket.provider.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 2eb8ddc2b4..c6ed09360a 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -103,7 +103,8 @@ class WebsocketNotifier extends StateNotifier { socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2); socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1); socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2); - socket.on('on_album_update', _handleAlbumUpdate); + socket.on('on_album_update', _handleRemoteChange); + socket.on('on_asset_stack_update', _handleRemoteChange); socket.on('on_config_update', _handleOnConfigUpdate); socket.on('on_new_release', _handleReleaseUpdates); } catch (e) { @@ -185,7 +186,7 @@ class WebsocketNotifier extends StateNotifier { unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data)); } - void _handleAlbumUpdate(dynamic _) { + void _handleRemoteChange(dynamic _) { unawaited(_ref.read(backgroundSyncProvider).syncRemote()); } From 46c42e0935bb5eab65e395478623eb65f097df2b Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 3 Aug 2026 16:50:28 +0200 Subject: [PATCH 142/163] chore: delete mergify config (#30521) --- .mergify.yml | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index 12f3ef6715..0000000000 --- a/.mergify.yml +++ /dev/null @@ -1,7 +0,0 @@ -merge_queue: - status_comments: outcomes - -queue_rules: - - name: default - batch_size: 3 - batch_max_wait_time: 2 min From 0d7147dceca9290c5f8b4fe8b3e3b138aac2afc2 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:18:24 +0200 Subject: [PATCH 143/163] fix: metadata extraction as LensModel can be a float (#30512) --- .../src/repositories/metadata.repository.ts | 6 +++++- server/src/services/metadata.service.ts | 4 +++- .../specs/services/metadata.service.spec.ts | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 94047bf326..1d504f6c71 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -20,7 +20,8 @@ type TagsWithWrongTypes = | 'TagsList' | 'Keywords' | 'HierarchicalSubject' - | 'ISO'; + | 'ISO' + | 'LensModel'; export interface ImmichTags extends Omit { ContentIdentifier?: string; @@ -43,6 +44,9 @@ export interface ImmichTags extends Omit { Description?: StringOrNumber; ImageDescription?: StringOrNumber; + // Apparently LensModel can also be a float: https://github.com/immich-app/immich/issues/30492 + LensModel?: StringOrNumber; + // Extended properties for image regions, such as faces RegionInfo?: { AppliedToDimensions: { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 171dcfe514..37dd92e27d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -117,7 +117,9 @@ const validateRange = (value: number | undefined, min: number, max: number): Non }; const getLensModel = (exifTags: ImmichTags): string | null => { - const lensModel = (exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '').trim(); + const lensModel = String( + exifTags.LensID ?? exifTags.LensType ?? exifTags.LensSpec ?? exifTags.LensModel ?? '', + ).trim(); if (lensModel === '----') { return null; } diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 6dc66e3ed5..37603520f7 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -152,4 +152,23 @@ describe(MetadataService.name, () => { ).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') }); }); }); + + it('should handle float lens models (#30492)', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ LensModel: 1.8 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .where('assetId', '=', asset.id) + .select('lensModel') + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ lensModel: '1.8' }); + }); }); From 29e7ea5302bc3f6ed1eb845706cc77cb34f40048 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:40:08 +0530 Subject: [PATCH 144/163] chore(web): use FUTO F-Droid repo in utilities (#30527) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- web/src/lib/modals/AppDownloadModal.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/modals/AppDownloadModal.svelte b/web/src/lib/modals/AppDownloadModal.svelte index 84d7630909..01a998bf5c 100644 --- a/web/src/lib/modals/AppDownloadModal.svelte +++ b/web/src/lib/modals/AppDownloadModal.svelte @@ -1,5 +1,5 @@