diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c9cbf4e7f5542e..10653afb5a3f12 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 } diff --git a/.github/workflows/auto-close.yml b/.github/workflows/auto-close.yml index db34e43d98808a..11f80e86323758 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 42a86431db8c5c..39a1a930779137 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 79d6b2b9ecb059..1b428fc67035b8 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 7f2a36c6cbcf05..85fb3ed38c0804 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 74d880ede88b34..12aae20be16fae 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 af700e390f70dd..e36bbda495bac9 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 c32188f7e12efc..f03cd804a4f068 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 0352f95bc0a605..99c2076cc76967 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 d0b00b538eab39..c83764bae30ab4 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 2d055498dd43a3..8d0467623fa7b6 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 54c1cb1a42200f..c970d2c0714a55 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 bdc13a3579ec36..bd580b72ade2f5 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 97ce581fa50727..395cb03fb7a9b9 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 8f39906507af23..adfdde6821e667 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 4fac7a152b707c..0589fbccec0f9c 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 }} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 2398b971dd6521..9a24a56e9e4e28 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -154,7 +154,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9 + image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 0eb013942a71d8..d30aa99e910ce3 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -56,7 +56,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9 + image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 33cbc4016ad608..b577f8f6ba8455 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -61,7 +61,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9 + image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411 user: '1000:1000' security_opt: - no-new-privileges:true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 98444d87936d92..5a09f26f3b3125 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -49,7 +49,7 @@ services: redis: container_name: immich_redis - image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9 + image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 43dce899629366..50821433abc336 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -45,7 +45,7 @@ services: redis: container_name: immich-e2e-redis - image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9 + image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411 healthcheck: test: redis-cli ping || exit 1 diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index 744af3627a8bb7..d5da8f8445c2d6 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -49,6 +49,7 @@ export default typescriptEslint.config([ 'unicorn/isolated-functions': 'off', 'unicorn/prefer-promise-with-resolvers': 'off', 'unicorn/no-declarations-before-early-exit': 'off', + 'unicorn/prefer-simple-condition-first': 'off', curly: 2, 'prettier/prettier': 0, 'unicorn/name-replacements': 'off', diff --git a/e2e/package.json b/e2e/package.json index cce0c435c04ba8..7f9d9b9c29840b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -40,7 +40,7 @@ "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^70.0.0", + "eslint-plugin-unicorn": "^72.0.0", "exiftool-vendored": "^35.0.0", "globals": "^17.0.0", "luxon": "^3.4.4", diff --git a/e2e/src/ui/specs/asset-viewer/asset-viewer.e2e-spec.ts b/e2e/src/ui/specs/asset-viewer/asset-viewer.e2e-spec.ts index 082ff1f7a1c366..85798f6be3599f 100644 --- a/e2e/src/ui/specs/asset-viewer/asset-viewer.e2e-spec.ts +++ b/e2e/src/ui/specs/asset-viewer/asset-viewer.e2e-spec.ts @@ -38,7 +38,7 @@ test.describe('asset-viewer', () => { assets.push(...timeBucket); } for (const yearMonth of timelineRestData.buckets.keys()) { - const [year, month] = yearMonth.split('-'); + const [year, month] = yearMonth.split('-', 2); yearMonths.push(`${year}-${Number(month)}`); } }); diff --git a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts index 6f986df84f13b2..adbff0d3b8a540 100644 --- a/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts +++ b/e2e/src/ui/specs/timeline/timeline.e2e-spec.ts @@ -49,7 +49,7 @@ test.describe('Timeline', () => { assets.push(...timeBucket); } for (const yearMonth of timelineRestData.buckets.keys()) { - const [year, month] = yearMonth.split('-'); + const [year, month] = yearMonth.split('-', 2); yearMonths.push(`${year}-${Number(month)}`); } }); diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index e6af7fe3b52cd0..f629ec92b3981c 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -7,7 +7,7 @@ export const sleep = (ms: number) => { }; export const padYearMonth = (yearMonth: string) => { - const [year, month] = yearMonth.split('-'); + const [year, month] = yearMonth.split('-', 2); return `${year}-${month.padStart(2, '0')}`; }; diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt index 37a325e896282d..79d1148acbb45e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/ImmichApp.kt @@ -23,6 +23,6 @@ class ImmichApp : Application() { // as the previous start might have been killed without unlocking. if (BackgroundEngineLock.connectEngines > 0) return@postDelayed BackgroundWorkerApiImpl.enqueueBackgroundWorker(this) - }, 5000) + }, 15000) } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt index ce2de60c38625a..60387f434a116d 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/background/BackgroundWorker.kt @@ -15,6 +15,7 @@ import androidx.work.ListenableWorker import androidx.work.WorkerParameters import app.alextran.immich.MainActivity import app.alextran.immich.R +import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture import io.flutter.FlutterInjector @@ -61,6 +62,11 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : } override fun startWork(): ListenableFuture { + if (BackgroundWorkerPreferences(ctx).isLocked() && BackgroundEngineLock.connectEngines > 0) { + Log.i(TAG, "Foreground engine active, skipping background worker") + return Futures.immediateFuture(Result.success()) + } + Log.i(TAG, "Starting background upload worker") if (!loader.initialized()) { @@ -77,6 +83,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : showNotification(notificationConfig.first, notificationConfig.second) loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { + if (isStopped || isComplete) { + return@ensureInitializationCompleteAsync + } + engine = FlutterEngine(ctx) FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!) @@ -143,11 +153,17 @@ class BackgroundWorker(context: Context, params: WorkerParameters) : return } + val api = flutterApi + if (api == null) { + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + complete(Result.failure()) + } + return + } + Handler(Looper.getMainLooper()).postAtFrontOfQueue { - if (flutterApi != null) { - flutterApi?.cancel { - complete(Result.failure()) - } + api.cancel { + complete(Result.failure()) } } diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 83c7948aa3d33f..7f42677909094c 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" } }, { diff --git a/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift b/mobile/ios/Runner/Connectivity/ConnectivityApiImpl.swift index f104314faecd9e..e88b7a0fd66f98 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) } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 35a8f899a832e3..5141bb455f8713 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) { @@ -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/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 2c80385c347523..a443ffb9757a65 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -210,17 +210,19 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { await _deleteAssets(deletes); await _upsertAssets(updates); + // Drop every existing album link for each changed asset before re-adding the + // ones the native side reports. A moved asset only reports its new album here, + // so leaving the old link around makes the per-album delete sweep wipe the + // asset entirely (it is still linked to a bucket it no longer lives in). + await _db.batch((batch) async { + for (final assetId in assetAlbums.keys) { + batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.assetId.equals(assetId)); + } + }); // The ugly casting below is required for now because the generated code // casts the returned values from the platform during decoding them // and iterating over them causes the type to be List instead of // List - await _db.batch((batch) async { - assetAlbums.cast>().forEach((assetId, albumIds) { - for (final albumId in albumIds.cast().nonNulls) { - batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId)); - } - }); - }); await _db.batch((batch) async { assetAlbums.cast>().forEach((assetId, albumIds) { batch.insertAll( diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 92dd9b070a18be..b9cd41c960ed48 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/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index a664f46206930f..0dfae062dc6e5f 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); + } } } 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 1ab3f2039d2188..dc48ed57ecb173 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/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index abef9ceff8bfb9..25f99e236107cc 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -174,6 +174,12 @@ class _AssetViewerState extends ConsumerState { return; } + // The viewer is closing; don't flip the current asset now. Flipping it swaps + // the grid tile hero keys mid pop and animates the close on two tiles (#23779). + if (!mounted || !(ModalRoute.of(context)?.isActive ?? true)) { + return; + } + AssetViewer._setAsset(ref, asset); _preloader.preload(index, context.sizeData); _handleCasting(); 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 8438d5e8ac8f79..bcb9fc6fe32bf1 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/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index 5e4dae34bc180f..c49c805e3d4c93 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), + ], ); } } diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 25db76b077d146..37d3392d88068c 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 ed62b9a0e8c449..52e2d9e0b9239a 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'; @@ -37,18 +34,25 @@ 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 { final Logger _logger = Logger('ActionNotifier'); late ActionService _service; late ForegroundUploadService _foregroundUploadService; - late DownloadService _downloadService; late AssetService _assetService; ActionNotifier() : super(); @@ -58,29 +62,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) { @@ -393,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()); @@ -409,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( @@ -424,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 a4bbbae818c8a7..b392f5f4012982 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, @@ -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 8d9bd5bfe3606c..fa05f9a4cd0ecc 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/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index c578746a4c0480..ed1ffd9e3d3d55 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/repositories/drift_album_api_repository.dart b/mobile/lib/repositories/drift_album_api_repository.dart index ee57352fb1b42f..3a654b7511ef72 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/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index 3f2c36fa7e2b05..b84d6ebfe8a573 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/pubspec.yaml b/mobile/pubspec.yaml index 993de5c51a8744..f66d55dc9923db 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 diff --git a/mobile/test/infrastructure/repositories/local_album_repository_test.dart b/mobile/test/infrastructure/repositories/local_album_repository_test.dart index fae0e09171b7fd..bd4acca48391b5 100644 --- a/mobile/test/infrastructure/repositories/local_album_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_album_repository_test.dart @@ -2,6 +2,7 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; @@ -35,4 +36,75 @@ void main() { expect(albums[3].id, '2'); // excluded }); }); + + group('processDelta', () { + // Regression for #22844: an asset moved (not copied) into a backup album on + // Android was dropped. The delta reports only the asset's new album, and the + // stale link to its old album made the per-album delete sweep wipe the asset. + test('keeps an asset moved to another album that still holds other assets', () async { + final localAlbumRepo = mediumFactory.getRepository(); + + final moved = _localAsset('moved'); + final other = _localAsset('other'); + await localAlbumRepo.upsert( + mediumFactory.localAlbum(id: 'src', backupSelection: BackupSelection.none), + toUpsert: [moved, other], + ); + final anchor = _localAsset('anchor'); + await localAlbumRepo.upsert( + mediumFactory.localAlbum(id: 'dst', backupSelection: BackupSelection.selected), + toUpsert: [anchor], + ); + + // Delta reports the moved asset now living only in the destination album. + await localAlbumRepo.processDelta( + updates: [moved], + deletes: [], + assetAlbums: { + 'moved': ['dst'], + }, + ); + + // Per-album delete sweep, as sync() runs on Android. src now physically + // holds only `other`; dst holds `anchor` and the moved asset. + await localAlbumRepo.syncDeletes('src', ['other']); + await localAlbumRepo.syncDeletes('dst', ['anchor', 'moved']); + + final dstIds = (await localAlbumRepo.getAssets('dst')).map((a) => a.id).toSet(); + final srcIds = await localAlbumRepo.getAssetIds('src'); + + expect(dstIds, contains('moved')); // survived and linked to the backed-up album + expect(srcIds, isNot(contains('moved'))); // stale source link cleared + expect(srcIds, contains('other')); // untouched asset stays put + }); + + test('replaces album membership with exactly what the delta reports', () async { + final localAlbumRepo = mediumFactory.getRepository(); + + final moved = _localAsset('moved'); + await localAlbumRepo.upsert(mediumFactory.localAlbum(id: 'src'), toUpsert: [moved]); + await localAlbumRepo.upsert(mediumFactory.localAlbum(id: 'dst')); + + await localAlbumRepo.processDelta( + updates: [moved], + deletes: [], + assetAlbums: { + 'moved': ['dst'], + }, + ); + + expect(await localAlbumRepo.getAssetIds('src'), isEmpty); + expect(await localAlbumRepo.getAssetIds('dst'), ['moved']); + }); + }); } + +LocalAsset _localAsset(String id) => LocalAsset( + id: id, + name: '$id.jpg', + type: AssetType.image, + createdAt: DateTime(2024), + updatedAt: DateTime(2024), + playbackStyle: AssetPlaybackStyle.image, + isEdited: false, +); diff --git a/mobile/test/providers/infrastructure/action_provider_test.dart b/mobile/test/providers/infrastructure/action_provider_test.dart index be13e58f7366c3..7f5c8d4ec28bed 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)), ], 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 00000000000000..93c45c6687967e --- /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']); + }); +} diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 83e356320f2165..bcc74c654dcd2a 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -46,6 +46,7 @@ export default typescriptEslint.config([ 'unicorn/no-process-exit': 'off', 'unicorn/import-style': 'off', 'unicorn/consistent-class-member-order': 'off', + 'unicorn/prefer-simple-condition-first': 'off', curly: 2, // prefer the typescript-eslint type-aware version 'unicorn/require-array-sort-compare': 'off', diff --git a/packages/cli/package.json b/packages/cli/package.json index 47b43922ed69d4..1bc988f87728b1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,7 +33,7 @@ "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^70.0.0", + "eslint-plugin-unicorn": "^72.0.0", "globals": "^17.0.0", "mock-fs": "^5.2.0", "prettier": "^3.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4658f12e2cb21f..d4d3e4246a51a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,8 +157,8 @@ importers: specifier: ^5.1.3 version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.7.0(jiti@2.7.0)))(eslint@10.7.0(jiti@2.7.0))(prettier@3.9.5) eslint-plugin-unicorn: - specifier: ^70.0.0 - version: 70.0.0(eslint@10.7.0(jiti@2.7.0)) + specifier: ^72.0.0 + version: 72.0.0(eslint@10.7.0(jiti@2.7.0)) exiftool-vendored: specifier: ^35.0.0 version: 35.21.0 @@ -269,8 +269,8 @@ importers: specifier: ^5.1.3 version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.7.0(jiti@2.7.0)))(eslint@10.7.0(jiti@2.7.0))(prettier@3.9.5) eslint-plugin-unicorn: - specifier: ^70.0.0 - version: 70.0.0(eslint@10.7.0(jiti@2.7.0)) + specifier: ^72.0.0 + version: 72.0.0(eslint@10.7.0(jiti@2.7.0)) globals: specifier: ^17.0.0 version: 17.7.0 @@ -741,8 +741,8 @@ importers: specifier: ^5.1.3 version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.7.0(jiti@2.7.0)))(eslint@10.7.0(jiti@2.7.0))(prettier@3.9.5) eslint-plugin-unicorn: - specifier: ^70.0.0 - version: 70.0.0(eslint@10.7.0(jiti@2.7.0)) + specifier: ^72.0.0 + version: 72.0.0(eslint@10.7.0(jiti@2.7.0)) globals: specifier: ^17.0.0 version: 17.7.0 @@ -1006,8 +1006,8 @@ importers: specifier: ^3.12.4 version: 3.20.0(eslint@10.7.0(jiti@2.7.0))(svelte@5.56.5(@typescript-eslint/types@8.64.0)) eslint-plugin-unicorn: - specifier: ^70.0.0 - version: 70.0.0(eslint@10.7.0(jiti@2.7.0)) + specifier: ^72.0.0 + version: 72.0.0(eslint@10.7.0(jiti@2.7.0)) factory.ts: specifier: ^1.4.1 version: 1.4.2 @@ -5520,45 +5520,22 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.63.0': - resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.64.0': resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.63.0': - resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.64.0': resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.63.0': - resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.64.0': resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.63.0': - resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.64.0': resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5566,33 +5543,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.63.0': - resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.64.0': resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.63.0': - resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.64.0': resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.63.0': - resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.64.0': resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5600,10 +5560,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.63.0': - resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.64.0': resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6752,6 +6708,10 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -7643,8 +7603,8 @@ packages: svelte: optional: true - eslint-plugin-unicorn@70.0.0: - resolution: {integrity: sha512-uAF9xMcVvvhTfvusCgogJ1wh4To3q2KhVMw3i1Apf/ILTbxsCjscvraAZACsEurb7no2fdXblD3whcbVnjw5zg==} + eslint-plugin-unicorn@72.0.0: + resolution: {integrity: sha512-hqO6ksoOHO+ZhdseTuKRVQbx9U7PRO/cv8qAR1mctwzdVO2hYud8uS9luAhp43RJgziYgHAph8eHyipT8GL0ng==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -8053,6 +8013,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function-timeout@1.0.2: + resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} + engines: {node: '>=18'} + gauge@3.0.2: resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} engines: {node: '>=10'} @@ -8457,6 +8421,10 @@ packages: peerDependencies: postcss: ^8.1.0 + identifier-regex@1.1.0: + resolution: {integrity: sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==} + engines: {node: '>=18'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -8627,6 +8595,10 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-identifier@1.1.0: + resolution: {integrity: sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==} + engines: {node: '>=18'} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -9280,6 +9252,10 @@ packages: magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + make-asynchronous@1.1.0: + resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} + engines: {node: '>=18'} + make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -10067,6 +10043,10 @@ packages: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} + p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} engines: {node: '>=4'} @@ -10111,6 +10091,10 @@ packages: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -10980,6 +10964,10 @@ packages: quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + quote-js-string@0.1.0: + resolution: {integrity: sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==} + engines: {node: '>=22'} + railroad-diagrams@1.0.0: resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} @@ -11222,6 +11210,10 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -11762,6 +11754,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + super-regex@1.1.0: + resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} + engines: {node: '>=18'} + superagent@10.3.0: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} @@ -12064,6 +12060,10 @@ packages: thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + timers-ext@0.1.8: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} @@ -12250,6 +12250,10 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -12717,6 +12721,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -16373,8 +16380,8 @@ snapshots: '@koddsson/eslint-plugin-tscompat@0.2.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.63.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) - '@typescript-eslint/utils': 8.63.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/type-utils': 8.64.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) + '@typescript-eslint/utils': 8.64.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) browserslist: 4.28.6 transitivePeerDependencies: - eslint @@ -18531,15 +18538,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.63.0(@typescript/typescript6@6.0.2)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.64.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.64.0 - debug: 4.4.3 - typescript: '@typescript/typescript6@6.0.2' - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.64.0(@typescript/typescript6@6.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.64.0(@typescript/typescript6@6.0.2) @@ -18549,36 +18547,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.63.0': - dependencies: - '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/visitor-keys': 8.63.0 - '@typescript-eslint/scope-manager@8.64.0': dependencies: '@typescript-eslint/types': 8.64.0 '@typescript-eslint/visitor-keys': 8.64.0 - '@typescript-eslint/tsconfig-utils@8.63.0(@typescript/typescript6@6.0.2)': - dependencies: - typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/tsconfig-utils@8.64.0(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.63.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': - dependencies: - '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/utils': 8.63.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0)) - debug: 4.4.3 - eslint: 10.7.0(jiti@2.7.0) - ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) - typescript: '@typescript/typescript6@6.0.2' - transitivePeerDependencies: - - supports-color - '@typescript-eslint/type-utils@8.64.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': dependencies: '@typescript-eslint/types': 8.64.0 @@ -18591,25 +18568,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.63.0': {} - '@typescript-eslint/types@8.64.0': {} - '@typescript-eslint/typescript-estree@8.63.0(@typescript/typescript6@6.0.2)': - dependencies: - '@typescript-eslint/project-service': 8.63.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.63.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/visitor-keys': 8.63.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) - typescript: '@typescript/typescript6@6.0.2' - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.64.0(@typescript/typescript6@6.0.2)': dependencies: '@typescript-eslint/project-service': 8.64.0(@typescript/typescript6@6.0.2) @@ -18625,17 +18585,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.63.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.63.0 - '@typescript-eslint/types': 8.63.0 - '@typescript-eslint/typescript-estree': 8.63.0(@typescript/typescript6@6.0.2) - eslint: 10.7.0(jiti@2.7.0) - typescript: '@typescript/typescript6@6.0.2' - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.64.0(@typescript/typescript6@6.0.2)(eslint@10.7.0(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) @@ -18647,11 +18596,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.63.0': - dependencies: - '@typescript-eslint/types': 8.63.0 - eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.64.0': dependencies: '@typescript-eslint/types': 8.64.0 @@ -19818,6 +19762,8 @@ snapshots: content-type@2.0.0: {} + convert-hrtime@5.0.0: {} + convert-source-map@2.0.0: {} cookie-parser@1.4.7: @@ -20828,25 +20774,29 @@ snapshots: transitivePeerDependencies: - ts-node - eslint-plugin-unicorn@70.0.0(eslint@10.7.0(jiti@2.7.0)): + eslint-plugin-unicorn@72.0.0(eslint@10.7.0(jiti@2.7.0)): dependencies: - '@babel/helper-validator-identifier': 7.29.7 '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0(jiti@2.7.0)) + '@eslint/css-tree': 4.0.4 browserslist: 4.28.6 change-case: 5.4.4 ci-info: 4.4.0 core-js-compat: 3.49.0 detect-indent: 7.0.2 + entities: 4.5.0 eslint: 10.7.0(jiti@2.7.0) find-up-simple: 1.0.1 globals: 17.7.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 - jsesc: 3.1.0 + is-identifier: 1.1.0 pluralize: 8.0.0 + quote-js-string: 0.1.0 regjsparser: 0.13.2 + reserved-identifiers: 1.2.0 semver: 7.8.5 strip-indent: 4.1.1 + yaml: 2.9.0 eslint-scope@5.1.1: dependencies: @@ -21362,6 +21312,8 @@ snapshots: function-bind@1.1.2: {} + function-timeout@1.0.2: {} + gauge@3.0.2: dependencies: aproba: 2.1.0 @@ -21924,6 +21876,10 @@ snapshots: dependencies: postcss: 8.5.19 + identifier-regex@1.1.0: + dependencies: + reserved-identifiers: 1.2.0 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -22078,6 +22034,11 @@ snapshots: is-hexadecimal@2.0.1: {} + is-identifier@1.1.0: + dependencies: + identifier-regex: 1.1.0 + super-regex: 1.1.0 + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -22631,6 +22592,12 @@ snapshots: '@babel/types': 7.29.7 source-map-js: 1.2.1 + make-asynchronous@1.1.0: + dependencies: + p-event: 6.0.1 + type-fest: 4.41.0 + web-worker: 1.5.0 + make-dir@3.1.0: dependencies: semver: 6.3.1 @@ -23723,6 +23690,10 @@ snapshots: p-cancelable@3.0.0: {} + p-event@6.0.1: + dependencies: + p-timeout: 6.1.4 + p-finally@1.0.0: {} p-limit@2.3.0: @@ -23768,6 +23739,8 @@ snapshots: dependencies: p-finally: 1.0.0 + p-timeout@6.1.4: {} + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -24652,6 +24625,8 @@ snapshots: quickselect@3.0.0: {} + quote-js-string@0.1.0: {} + railroad-diagrams@1.0.0: {} randexp@0.4.6: @@ -24999,6 +24974,8 @@ snapshots: requires-port@1.0.0: {} + reserved-identifiers@1.2.0: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -25706,6 +25683,12 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + super-regex@1.1.0: + dependencies: + function-timeout: 1.0.2 + make-asynchronous: 1.1.0 + time-span: 5.1.0 + superagent@10.3.0: dependencies: component-emitter: 1.3.1 @@ -26106,6 +26089,10 @@ snapshots: thunky@1.1.0: {} + time-span@5.1.0: + dependencies: + convert-hrtime: 5.0.0 + timers-ext@0.1.8: dependencies: es5-ext: 0.10.64 @@ -26266,6 +26253,8 @@ snapshots: type-fest@2.19.0: {} + type-fest@4.41.0: {} + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 @@ -26749,6 +26738,8 @@ snapshots: web-namespaces@2.0.1: {} + web-worker@1.5.0: {} + webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 579993aec5ac4b..2cca2cab6d9fa1 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -64,6 +64,7 @@ export default typescriptEslint.config([ 'unicorn/consistent-boolean-name': 'off', 'unicorn/no-computed-property-existence-check': 'off', 'unicorn/no-non-function-verb-prefix': 'off', + 'unicorn/prefer-simple-condition-first': 'off', // prefer the typescript-eslint type-aware version 'unicorn/require-array-sort-compare': 'off', '@typescript-eslint/require-array-sort-compare': 'error', diff --git a/server/package.json b/server/package.json index 67b4ca72a91d63..23cbe12e5cd768 100644 --- a/server/package.json +++ b/server/package.json @@ -151,7 +151,7 @@ "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^70.0.0", + "eslint-plugin-unicorn": "^72.0.0", "globals": "^17.0.0", "mock-fs": "^5.2.0", "pngjs": "^7.0.0", diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index e551fc840fd312..c357c1343062de 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -128,7 +128,7 @@ export class AssetMediaController { this.logger.deprecate( 'Calling the thumbnail endpoint with size=original is deprecated. Use the :id/original endpoint instead', ); - const [_, reqSearch] = req.url.split('?'); + const [_, reqSearch] = req.url.split('?', 2); const redirSearchParams = new URLSearchParams(reqSearch); redirSearchParams.delete('size'); return res.redirect('original?' + redirSearchParams.toString()); @@ -142,7 +142,7 @@ export class AssetMediaController { // viewThumbnailRes is a AssetMediaRedirectResponse // which redirects to the original asset or a specific size to make better use of caching const { targetSize } = viewThumbnailRes; - const [reqPath, reqSearch] = req.url.split('?'); + const [reqPath, reqSearch] = req.url.split('?', 2); let redirPath: string; const redirSearchParams = new URLSearchParams(reqSearch); if (targetSize === 'original') { diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index a29b6f7cc3ad43..f68f2dbe95bb42 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/event.repository.ts b/server/src/repositories/event.repository.ts index 82e67ce97c36d3..416f823952296d 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/media.repository.ts b/server/src/repositories/media.repository.ts index 181b1eada931a8..f0856487c532ad 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -313,7 +313,7 @@ export class MediaRepository { if (!line) { return; } - const [ptsStr, durationStr, flags] = line.split(','); + const [ptsStr, durationStr, flags] = line.split(',', 3); const pts = Number.parseInt(ptsStr); const duration = Number.parseInt(durationStr); if (Number.isNaN(pts) || Number.isNaN(duration) || !flags) { diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index c3f199d2468190..1a09fff70d9d20 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 451b2263e537b3..f1cb541b392726 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/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index d79e1563e3fbf8..3b4328563a8ab3 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/schema/migrations/1784647658615-AddOAuthBearerTokenToSession.ts b/server/src/schema/migrations/1784647658615-AddOAuthBearerTokenToSession.ts new file mode 100644 index 00000000000000..01994a2ab5dff1 --- /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 950c1eeffdf04d..68381b7f8ec677 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/album.service.spec.ts b/server/src/services/album.service.spec.ts index 6e89c9aca8abe5..a97407b2d969b4 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 ab86bcf2d462c9..071c5d5aec9a19 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/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index ed5b553a5bb102..5c76b3854201fb 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 288ef092c7c068..d30c912194f463 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,15 +459,24 @@ 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; + } + + const url = new URL(endSessionEndpoint); + + if (oauthBearerToken) { + url.searchParams.set('id_token_hint', oauthBearerToken); } - return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL; + return url.href; } private getBearerToken(headers: IncomingHttpHeaders): string | null { - const [type, token] = (headers.authorization || '').split(' '); + const [type, token] = (headers.authorization || '').split(' ', 2); if (type.toLowerCase() === 'bearer') { return token; } @@ -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/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index 99a3c5bf1f7315..a277b028a54497 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -214,7 +214,6 @@ export class DatabaseBackupService { bin: `/usr/lib/postgresql/${databaseMajorVersion}/bin/${bin}`, args, databaseUsername, - // eslint-disable-next-line unicorn/prefer-minimal-ternary databasePassword: isUrlConnection ? new URL(databaseConfig.url).password : databaseConfig.password, databaseVersion, databaseMajorVersion, diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 751806f2663e21..ae06232605521a 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -15,6 +15,7 @@ import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { vitest } from 'vitest'; async function* mockWalk() { + // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject yield await Promise.resolve(['/data/user1/photo.jpg']); } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 0d51db7ac865ae..d6fcefbe6a65cd 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 e688f7dc7ffd3f..a650b466baab1c 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/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 2762b9e754e051..faa4f8d423bab9 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -51,7 +51,7 @@ export class SystemConfigService extends BaseService { @OnEvent({ name: 'ConfigValidate' }) onConfigValidate({ newConfig, oldConfig }: ArgOf<'ConfigValidate'>) { const { logLevel } = this.configRepository.getEnv(); - if (!_.isEqual(toPlainObject(newConfig.logging), oldConfig.logging) && logLevel) { + if (logLevel && !_.isEqual(toPlainObject(newConfig.logging), oldConfig.logging)) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } } diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 327cf5d73ceed4..bb5e7be95b8527 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -172,7 +172,7 @@ export const mimeTypes = { return AssetType.Image; } - if (contentType.startsWith('video/') || contentType === 'application/mxf') { + if (contentType === 'application/mxf' || contentType.startsWith('video/')) { return AssetType.Video; } diff --git a/server/src/utils/sync.ts b/server/src/utils/sync.ts index 82222708af97d1..51c62862ebec3a 100644 --- a/server/src/utils/sync.ts +++ b/server/src/utils/sync.ts @@ -9,7 +9,7 @@ type Impossible = { type Exact = U & Impossible>; export const fromAck = (ack: string): SyncAck => { - const [type, updateId, extraId] = ack.split('|'); + const [type, updateId, extraId] = ack.split('|', 3); return { type: type as SyncEntityType, updateId, extraId }; }; diff --git a/server/test/factories/session.factory.ts b/server/test/factories/session.factory.ts index 44a25edcfa904f..188b8b638cb685 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/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index a9a9e174e5baf8..d8830e91e6f5d9 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(); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 1c17d955a924ad..ae83727d7070a0 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' diff --git a/web/eslint.config.js b/web/eslint.config.js index 012a74b4fa5528..de378d11c71c00 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -146,6 +146,7 @@ export default typescriptEslint.config( 'unicorn/no-non-function-verb-prefix': 'off', 'unicorn/prefer-minimal-ternary': 'off', 'unicorn/no-empty-file': 'off', + 'unicorn/prefer-simple-condition-first': 'off', // prefer the typescript-eslint type-aware version 'unicorn/require-array-sort-compare': 'off', '@typescript-eslint/require-array-sort-compare': 'error', diff --git a/web/package.json b/web/package.json index c4d80b5098a766..4e3140085e558f 100644 --- a/web/package.json +++ b/web/package.json @@ -97,7 +97,7 @@ "eslint-plugin-better-tailwindcss": "^4.5.0", "eslint-plugin-compat": "^7.0.0", "eslint-plugin-svelte": "^3.12.4", - "eslint-plugin-unicorn": "^70.0.0", + "eslint-plugin-unicorn": "^72.0.0", "factory.ts": "^1.4.1", "globals": "^17.0.0", "happy-dom": "^20.0.0", diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 549400d9ca202a..0673285657234e 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[] = [ diff --git a/web/src/lib/components/ApiKeyPermissionsPicker.svelte b/web/src/lib/components/ApiKeyPermissionsPicker.svelte index 9f5ddc74d4795a..6eb2ca7cf10d08 100644 --- a/web/src/lib/components/ApiKeyPermissionsPicker.svelte +++ b/web/src/lib/components/ApiKeyPermissionsPicker.svelte @@ -17,7 +17,7 @@ continue; } - const [group] = permission.split('.'); + const [group] = permission.split('.', 1); if (!Object.hasOwn(permissions, group)) { permissions[group] = []; } diff --git a/web/src/lib/components/Image.svelte b/web/src/lib/components/Image.svelte index 7a42cf33bbb926..bec5908177f7e7 100644 --- a/web/src/lib/components/Image.svelte +++ b/web/src/lib/components/Image.svelte @@ -50,7 +50,7 @@ } if (isFirefox && ref) { - ref.decode().then(completeLoad, completeLoad); + ref.decode().then(completeLoad).catch(completeLoad); return; } diff --git a/web/src/lib/components/asset-viewer/SlideshowBar.svelte b/web/src/lib/components/asset-viewer/SlideshowBar.svelte index e8e6b8061e2f27..0ec5fe7dc0c73c 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 }} diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/TransformTool.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/TransformTool.svelte index 5bc50f9431d42f..b86f5b7edfa468 100644 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/TransformTool.svelte +++ b/web/src/lib/components/asset-viewer/editor/transform-tool/TransformTool.svelte @@ -35,7 +35,7 @@ } if (isRotated) { - let [width, height] = ratio.value.split(':'); + let [width, height] = ratio.value.split(':', 2); return `${height}:${width}`; } return ratio.value; diff --git a/web/src/lib/elements/__test__/FormatMessage.spec.ts b/web/src/lib/elements/__test__/FormatMessage.spec.ts index af51b66f45afa3..dc126803929706 100644 --- a/web/src/lib/elements/__test__/FormatMessage.spec.ts +++ b/web/src/lib/elements/__test__/FormatMessage.spec.ts @@ -5,7 +5,7 @@ import { describe } from 'vitest'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; import FormatTagB from '$lib/elements/__test__/FormatTagB.svelte'; -const getSanitizedHTML = (container: HTMLElement) => container.innerHTML.replaceAll('', ''); +const getSanitizedHTML = (container: HTMLElement) => container.getHTML().replaceAll('', ''); describe('FormatMessage component', () => { beforeAll(async () => { diff --git a/web/src/lib/managers/auth-manager.svelte.ts b/web/src/lib/managers/auth-manager.svelte.ts index 4b1743b9d2573f..a34deb3b58003b 100644 --- a/web/src/lib/managers/auth-manager.svelte.ts +++ b/web/src/lib/managers/auth-manager.svelte.ts @@ -124,7 +124,7 @@ class AuthManager { } for (const cookie of document.cookie.split('; ')) { - const [name] = cookie.split('='); + const [name] = cookie.split('=', 1); if (name === 'immich_is_authenticated') { return true; } diff --git a/web/src/lib/managers/edit/transform-manager.svelte.ts b/web/src/lib/managers/edit/transform-manager.svelte.ts index 9379138a1bab8b..9704af420b4d21 100644 --- a/web/src/lib/managers/edit/transform-manager.svelte.ts +++ b/web/src/lib/managers/edit/transform-manager.svelte.ts @@ -834,7 +834,7 @@ class TransformManager implements EditToolManager { return; } - const [widthRatio, heightRatio] = aspectRatio.split(':'); + const [widthRatio, heightRatio] = aspectRatio.split(':', 2); this.setAspectRatio(`${heightRatio}:${widthRatio}`); } diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index 058cdc8e6f689d..2acb885d4a9793 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -140,7 +140,7 @@ city: emptyStringToNull(filter.location.city), make: emptyStringToNull(filter.camera.make), model: emptyStringToNull(filter.camera.model), - lensModel: filter.camera.lensModel, + lensModel: emptyStringToNull(filter.camera.lensModel), takenAfter: filter.date.takenAfter ? asLocalTimeISO(filter.date.takenAfter.startOf('day') as DateTime) : undefined, diff --git a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte index ef671c399a8692..df484fba816915 100644 --- a/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte +++ b/web/src/routes/(user)/memory/[[photos=photos]]/[[assetId=id]]/MemoryViewer.svelte @@ -281,8 +281,9 @@ }; afterNavigate(({ from, to }) => { - memoryManager.ready().then( - () => { + memoryManager + .ready() + .then(() => { let target; if (to?.params?.assetId) { target = to; @@ -294,11 +295,10 @@ init(target); initPlayer(); - }, - (error) => { + }) + .catch((error) => { console.error(`Error loading memories: ${error}`); - }, - ); + }); }); $effect(() => {