Merge remote-tracking branch 'origin/main' into improve_focus

This commit is contained in:
Min Idzelis 2025-04-30 04:04:30 +00:00
commit 5cce108568
79 changed files with 1920 additions and 1299 deletions

View File

@ -96,7 +96,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@ -50,7 +50,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3 uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@ -63,7 +63,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3 uses: github/codeql-action/autobuild@28deaeda66b76a05916b6923827895f2b14ab387 # v3
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3 uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

@ -205,7 +205,7 @@ jobs:
- name: Build and push image - name: Build and push image
id: build id: build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
@ -266,7 +266,7 @@ jobs:
- build_and_push_ml - build_and_push_ml
steps: steps:
- name: Download digests - name: Download digests
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: ml-digests-${{ matrix.device }}-* pattern: ml-digests-${{ matrix.device }}-*
@ -407,7 +407,7 @@ jobs:
- name: Build and push image - name: Build and push image
id: build id: build
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
@ -454,7 +454,7 @@ jobs:
- build_and_push_server - build_and_push_server
steps: steps:
- name: Download digests - name: Download digests
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: server-digests-* pattern: server-digests-*

View File

@ -72,4 +72,5 @@ jobs:
with: with:
name: docs-build-output name: docs-build-output
path: docs/build/ path: docs/build/
include-hidden-files: true
retention-days: 1 retention-days: 1

View File

@ -95,7 +95,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Download APK - name: Download APK
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: release-apk-signed name: release-apk-signed

View File

@ -105,12 +105,12 @@ jobs:
actions: read actions: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with: with:
persist-credentials: false persist-credentials: false
- name: Install the latest version of uv - name: Install the latest version of uv
uses: astral-sh/setup-uv@v5 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- name: Run zizmor 🌈 - name: Run zizmor 🌈
run: uvx zizmor --format=sarif . > results.sarif run: uvx zizmor --format=sarif . > results.sarif
@ -118,7 +118,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file - name: Upload SARIF file
uses: github/codeql-action/upload-sarif@v3 uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3
with: with:
sarif_file: results.sarif sarif_file: results.sarif
category: zizmor category: zizmor

View File

@ -461,7 +461,7 @@ jobs:
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818) # TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with: # with:
# python-version: 3.11 # python-version: 3.11

View File

@ -1,4 +1,4 @@
FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS core FROM node:22.15.0-alpine3.20@sha256:686b8892b69879ef5bfd6047589666933508f9a5451c67320df3070ba0e9807b AS core
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

254
cli/package-lock.json generated
View File

@ -647,9 +647,9 @@
} }
}, },
"node_modules/@eslint/core": { "node_modules/@eslint/core": {
"version": "0.12.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
"integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -697,9 +697,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.24.0", "version": "9.25.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.24.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.25.1.tgz",
"integrity": "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA==", "integrity": "sha512-dEIwmjntEx8u3Uvv+kr3PDeeArL8Hw07H9kyYxCjnM9pBjfEhk6uLXSchxxzgiwtRhhzVzqmUSDFBOi1TuZ7qg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -730,19 +730,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz",
"integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1380,17 +1367,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz",
"integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", "integrity": "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/scope-manager": "8.31.0",
"@typescript-eslint/type-utils": "8.30.1", "@typescript-eslint/type-utils": "8.31.0",
"@typescript-eslint/utils": "8.30.1", "@typescript-eslint/utils": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1", "@typescript-eslint/visitor-keys": "8.31.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -1410,16 +1397,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.0.tgz",
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/scope-manager": "8.31.0",
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/typescript-estree": "8.30.1", "@typescript-eslint/typescript-estree": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1", "@typescript-eslint/visitor-keys": "8.31.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -1435,14 +1422,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.0.tgz",
"integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", "integrity": "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1" "@typescript-eslint/visitor-keys": "8.31.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1453,14 +1440,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.0.tgz",
"integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", "integrity": "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.30.1", "@typescript-eslint/typescript-estree": "8.31.0",
"@typescript-eslint/utils": "8.30.1", "@typescript-eslint/utils": "8.31.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.0.1" "ts-api-utils": "^2.0.1"
}, },
@ -1477,9 +1464,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.0.tgz",
"integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==", "integrity": "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1491,14 +1478,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.0.tgz",
"integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", "integrity": "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1", "@typescript-eslint/visitor-keys": "8.31.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -1544,16 +1531,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.0.tgz",
"integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", "integrity": "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/scope-manager": "8.31.0",
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/typescript-estree": "8.30.1" "@typescript-eslint/typescript-estree": "8.31.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1568,13 +1555,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.0.tgz",
"integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", "integrity": "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
@ -1586,9 +1573,9 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.2.tgz",
"integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==", "integrity": "sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1601,7 +1588,7 @@
"istanbul-reports": "^3.1.7", "istanbul-reports": "^3.1.7",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"magicast": "^0.3.5", "magicast": "^0.3.5",
"std-env": "^3.8.1", "std-env": "^3.9.0",
"test-exclude": "^7.0.1", "test-exclude": "^7.0.1",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@ -1609,8 +1596,8 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"@vitest/browser": "3.1.1", "@vitest/browser": "3.1.2",
"vitest": "3.1.1" "vitest": "3.1.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@vitest/browser": { "@vitest/browser": {
@ -1619,14 +1606,14 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz",
"integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.1.1", "@vitest/spy": "3.1.2",
"@vitest/utils": "3.1.1", "@vitest/utils": "3.1.2",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@ -1635,13 +1622,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz",
"integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.1.1", "@vitest/spy": "3.1.2",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
@ -1662,9 +1649,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
"integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1675,13 +1662,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz",
"integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.1.1", "@vitest/utils": "3.1.2",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@ -1689,13 +1676,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz",
"integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.1.1", "@vitest/pretty-format": "3.1.2",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@ -1704,9 +1691,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz",
"integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1717,13 +1704,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz",
"integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.1.1", "@vitest/pretty-format": "3.1.2",
"loupe": "^3.1.3", "loupe": "^3.1.3",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@ -2183,9 +2170,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.6.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -2254,20 +2241,20 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.24.0", "version": "9.25.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.25.1.tgz",
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", "integrity": "sha512-E6Mtz9oGQWDCpV12319d59n4tx9zOTXSTmc8BLVxBx+G/0RdM5MvEEJLU9c0+aleoePYYgVTOsRblx433qmhWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.20.0", "@eslint/config-array": "^0.20.0",
"@eslint/config-helpers": "^0.2.0", "@eslint/config-helpers": "^0.2.1",
"@eslint/core": "^0.12.0", "@eslint/core": "^0.13.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.24.0", "@eslint/js": "9.25.1",
"@eslint/plugin-kit": "^0.2.7", "@eslint/plugin-kit": "^0.2.8",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2", "@humanwhocodes/retry": "^0.4.2",
@ -4197,15 +4184,15 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.30.1.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.0.tgz",
"integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", "integrity": "sha512-u+93F0sB0An8WEAPtwxVhFby573E8ckdjwUUQUj9QA4v8JAvgtoDdIyYR3XFwFHq2W1KJ1AurwJCO+w+Y1ixyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.30.1", "@typescript-eslint/eslint-plugin": "8.31.0",
"@typescript-eslint/parser": "8.30.1", "@typescript-eslint/parser": "8.31.0",
"@typescript-eslint/utils": "8.30.1" "@typescript-eslint/utils": "8.31.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -4292,18 +4279,18 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.2", "version": "6.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz",
"integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.3", "fdir": "^6.4.4",
"picomatch": "^4.0.2", "picomatch": "^4.0.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"rollup": "^4.34.9", "rollup": "^4.34.9",
"tinyglobby": "^0.2.12" "tinyglobby": "^0.2.13"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@ -4367,9 +4354,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz",
"integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4438,31 +4425,32 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz",
"integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "3.1.1", "@vitest/expect": "3.1.2",
"@vitest/mocker": "3.1.1", "@vitest/mocker": "3.1.2",
"@vitest/pretty-format": "^3.1.1", "@vitest/pretty-format": "^3.1.2",
"@vitest/runner": "3.1.1", "@vitest/runner": "3.1.2",
"@vitest/snapshot": "3.1.1", "@vitest/snapshot": "3.1.2",
"@vitest/spy": "3.1.1", "@vitest/spy": "3.1.2",
"@vitest/utils": "3.1.1", "@vitest/utils": "3.1.2",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.0", "debug": "^4.4.0",
"expect-type": "^1.2.0", "expect-type": "^1.2.1",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"std-env": "^3.8.1", "std-env": "^3.9.0",
"tinybench": "^2.9.0", "tinybench": "^2.9.0",
"tinyexec": "^0.3.2", "tinyexec": "^0.3.2",
"tinyglobby": "^0.2.13",
"tinypool": "^1.0.2", "tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0", "tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0", "vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.1.1", "vite-node": "3.1.2",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@ -4478,8 +4466,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.1.1", "@vitest/browser": "3.1.2",
"@vitest/ui": "3.1.1", "@vitest/ui": "3.1.2",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },

View File

@ -116,7 +116,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 image: docker.io/valkey/valkey:8-bookworm@sha256:c855f98e09d558a0d7cc1a4e56473231206a4c54c0114ada9c485b47aeb92ec8
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1

View File

@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 image: docker.io/valkey/valkey:8-bookworm@sha256:c855f98e09d558a0d7cc1a4e56473231206a4c54c0114ada9c485b47aeb92ec8
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@ -102,7 +102,7 @@ services:
command: [ './run.sh', '-disable-reporting' ] command: [ './run.sh', '-disable-reporting' ]
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:11.6.0-ubuntu@sha256:fd8fa48213c624e1a95122f1d93abbf1cf1cbe85fc73212c1e599dbd76c63ff8 image: grafana/grafana:11.6.1-ubuntu@sha256:6fc273288470ef499dd3c6b36aeade093170d4f608f864c5dd3a7fabeae77b50
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@ -49,7 +49,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1 image: docker.io/valkey/valkey:8-bookworm@sha256:c855f98e09d558a0d7cc1a4e56473231206a4c54c0114ada9c485b47aeb92ec8
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always

5
docs/static/.well-known/security.txt vendored Normal file
View File

@ -0,0 +1,5 @@
Policy: https://github.com/immich-app/immich/blob/main/SECURITY.md
Contact: mailto:security@immich.app
Preferred-Languages: en
Expires: 2026-05-01T23:59:00.000Z
Canonical: https://immich.app/.well-known/security.txt

View File

@ -34,7 +34,7 @@ services:
- 2285:2285 - 2285:2285
redis: redis:
image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
database: database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52

744
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -192,26 +192,22 @@
"oauth_auto_register": "Auto register", "oauth_auto_register": "Auto register",
"oauth_auto_register_description": "Automatically register new users after signing in with OAuth", "oauth_auto_register_description": "Automatically register new users after signing in with OAuth",
"oauth_button_text": "Button text", "oauth_button_text": "Button text",
"oauth_client_id": "Client ID", "oauth_client_secret_description": "Required if PKCE (Proof Key for Code Exchange) is not supported by the OAuth provider",
"oauth_client_secret": "Client Secret",
"oauth_enable_description": "Login with OAuth", "oauth_enable_description": "Login with OAuth",
"oauth_issuer_url": "Issuer URL",
"oauth_mobile_redirect_uri": "Mobile redirect URI", "oauth_mobile_redirect_uri": "Mobile redirect URI",
"oauth_mobile_redirect_uri_override": "Mobile redirect URI override", "oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
"oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like '{callback}'", "oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like '{callback}'",
"oauth_profile_signing_algorithm": "Profile signing algorithm",
"oauth_profile_signing_algorithm_description": "Algorithm used to sign the user profile.",
"oauth_scope": "Scope",
"oauth_settings": "OAuth", "oauth_settings": "OAuth",
"oauth_settings_description": "Manage OAuth login settings", "oauth_settings_description": "Manage OAuth login settings",
"oauth_settings_more_details": "For more details about this feature, refer to the <link>docs</link>.", "oauth_settings_more_details": "For more details about this feature, refer to the <link>docs</link>.",
"oauth_signing_algorithm": "Signing algorithm",
"oauth_storage_label_claim": "Storage label claim", "oauth_storage_label_claim": "Storage label claim",
"oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.", "oauth_storage_label_claim_description": "Automatically set the user's storage label to the value of this claim.",
"oauth_storage_quota_claim": "Storage quota claim", "oauth_storage_quota_claim": "Storage quota claim",
"oauth_storage_quota_claim_description": "Automatically set the user's storage quota to the value of this claim.", "oauth_storage_quota_claim_description": "Automatically set the user's storage quota to the value of this claim.",
"oauth_storage_quota_default": "Default storage quota (GiB)", "oauth_storage_quota_default": "Default storage quota (GiB)",
"oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota).", "oauth_storage_quota_default_description": "Quota in GiB to be used when no claim is provided (Enter 0 for unlimited quota).",
"oauth_timeout": "Request Timeout",
"oauth_timeout_description": "Timeout for requests in milliseconds",
"offline_paths": "Offline Paths", "offline_paths": "Offline Paths",
"offline_paths_description": "These results may be due to manual deletion of files that are not part of an external library.", "offline_paths_description": "These results may be due to manual deletion of files that are not part of an external library.",
"password_enable_description": "Login with email and password", "password_enable_description": "Login with email and password",
@ -1230,6 +1226,8 @@
"month": "Month", "month": "Month",
"monthly_title_text_date_format": "MMMM y", "monthly_title_text_date_format": "MMMM y",
"more": "More", "more": "More",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash", "moved_to_trash": "Moved to trash",
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
"multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping",

View File

@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:a3e280261e448b95d49423532ccd6e5329c39d171c10df1457891ff7c5e2301b AS builder-cpu FROM python:3.11-bookworm@sha256:ab60e444e04215a62671149f24c59cc2893b49cb5dad26f9d139077a86be760e AS builder-cpu
FROM builder-cpu AS builder-openvino FROM builder-cpu AS builder-openvino
@ -54,7 +54,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends g++ RUN apt-get update && apt-get install -y --no-install-recommends g++
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:db305ce8edc1c2df4988b9d23471465d90d599cc55571e6501421c173a33bb0b /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest@sha256:4a6c9444b126bd325fba904bff796bf91fb777bf6148d60109c4cb1de2ffc497 /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
@ -63,11 +63,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \ uv pip install /opt/onnxruntime_rocm-*.whl; \
fi fi
FROM python:3.11-slim-bookworm@sha256:82c07f2f6e35255b92eb16f38dbd22679d5e8fb523064138d7c6468e7bf0c15b AS prod-cpu FROM python:3.11-slim-bookworm@sha256:97ef3198ec8c78690587167bb6a4905d00ffe053900687bdae93ad667e507cbb AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
FROM python:3.11-slim-bookworm@sha256:82c07f2f6e35255b92eb16f38dbd22679d5e8fb523064138d7c6468e7bf0c15b AS prod-openvino FROM python:3.11-slim-bookworm@sha256:97ef3198ec8c78690587167bb6a4905d00ffe053900687bdae93ad667e507cbb AS prod-openvino
RUN apt-get update && \ RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

142
machine-learning/uv.lock generated
View File

@ -69,6 +69,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/cd/d6d9bb1dadf73e7af02d18225cbd2c93f8552e13130484f1c8dcfece292b/anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee", size = 85481, upload_time = "2023-12-16T17:06:55.989Z" }, { url = "https://files.pythonhosted.org/packages/bf/cd/d6d9bb1dadf73e7af02d18225cbd2c93f8552e13130484f1c8dcfece292b/anyio-4.2.0-py3-none-any.whl", hash = "sha256:745843b39e829e108e518c489b31dc757de7d2131d53fac32bd8df268227bfee", size = 85481, upload_time = "2023-12-16T17:06:55.989Z" },
] ]
[[package]]
name = "bidict"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload_time = "2024-02-18T19:09:05.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload_time = "2024-02-18T19:09:04.156Z" },
]
[[package]] [[package]]
name = "black" name = "black"
version = "25.1.0" version = "25.1.0"
@ -1200,15 +1209,16 @@ wheels = [
[[package]] [[package]]
name = "locust" name = "locust"
version = "2.35.0" version = "2.36.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "configargparse" }, { name = "configargparse" },
{ name = "flask" }, { name = "flask" },
{ name = "flask-cors" }, { name = "flask-cors" },
{ name = "flask-login" }, { name = "flask-login" },
{ name = "gevent", marker = "python_full_version != '3.13.*'" }, { name = "gevent" },
{ name = "geventhttpclient" }, { name = "geventhttpclient" },
{ name = "locust-cloud" },
{ name = "msgpack" }, { name = "msgpack" },
{ name = "psutil" }, { name = "psutil" },
{ name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "pywin32", marker = "sys_platform == 'win32'" },
@ -1219,9 +1229,25 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "werkzeug" }, { name = "werkzeug" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/79/21/d5aeeee74173d73d7d8d392e307ec24d8281fca69a2bf1f19199bd84c498/locust-2.35.0.tar.gz", hash = "sha256:97f83e591646ca3227644cfb6d4fa590e9a3e3d791ab18b216ca98be235b9b24", size = 2240690, upload_time = "2025-04-16T12:10:25.037Z" } sdist = { url = "https://files.pythonhosted.org/packages/6d/90/55d4fbc8911e5e6ec4072caaca9e8b7b2b11279435c0d1330c9966b0c898/locust-2.36.2.tar.gz", hash = "sha256:604aff7535f5a83b7f666d32373b2dc74ad260c7c3d1dc274f4c82844be72eb6", size = 2251110, upload_time = "2025-04-25T14:03:35.919Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/15/9e92757c08af3f0c0168ab6480315ed6374396d635f70055df5c42cfa672/locust-2.35.0-py3-none-any.whl", hash = "sha256:fb9e0ec25c5db3ed6a3c6d48e7236d7c2c370b0ddae102e9badcb2d3d101abde", size = 2258054, upload_time = "2025-04-16T12:10:22.608Z" }, { url = "https://files.pythonhosted.org/packages/ab/f5/99dab104be69122eee3513dcdc6e0b32d59ca1f4cfd8715470c5f3aa7643/locust-2.36.2-py3-none-any.whl", hash = "sha256:74239f493f44035b25a87a0665deadf41d213b3dcd45774398e511dec15e26eb", size = 2267937, upload_time = "2025-04-25T14:03:33.671Z" },
]
[[package]]
name = "locust-cloud"
version = "1.20.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "configargparse" },
{ name = "gevent" },
{ name = "platformdirs" },
{ name = "python-socketio", extra = ["client"] },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/47/1ec2478f3d4e526fb8d667b01a75b22093b2e66aea665b5369dd656ceec9/locust_cloud-1.20.7.tar.gz", hash = "sha256:24c16b767adffab51b97f489bcf142e16e2439354fb4296ecbb3e87ad20e220a", size = 448622, upload_time = "2025-04-28T11:01:49.381Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/07/62b5b174c77d4281235405f1ffd439f12a877e434e007a24a5299c461e39/locust_cloud-1.20.7-py3-none-any.whl", hash = "sha256:f38214e77993d0ee87114dafa857e1689789ed4bfe4ae57c2b9dc754674f08bc", size = 406619, upload_time = "2025-04-28T11:01:43.135Z" },
] ]
[[package]] [[package]]
@ -1722,11 +1748,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.1.0" version = "4.3.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/62/d1/7feaaacb1a3faeba96c06e6c5091f90695cc0f94b7e8e1a3a3fe2b33ff9a/platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420", size = 19760, upload_time = "2023-12-04T15:32:15.925Z" } sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291, upload_time = "2025-03-19T20:36:10.989Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/53/42fe5eab4a09d251a76d0043e018172db324a23fcdac70f77a551c11f618/platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380", size = 17420, upload_time = "2023-12-04T15:32:13.795Z" }, { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload_time = "2025-03-19T20:36:09.038Z" },
] ]
[[package]] [[package]]
@ -2005,6 +2031,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482, upload_time = "2023-02-24T06:46:36.009Z" }, { url = "https://files.pythonhosted.org/packages/44/2f/62ea1c8b593f4e093cc1a7768f0d46112107e790c3e478532329e434f00b/python_dotenv-1.0.0-py3-none-any.whl", hash = "sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a", size = 19482, upload_time = "2023-02-24T06:46:36.009Z" },
] ]
[[package]]
name = "python-engineio"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "simple-websocket" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/e1/eee1129544b7f78fa2afa9fa0fce153cdcb21015b9b331d1b8adf90f45cb/python_engineio-4.12.0.tar.gz", hash = "sha256:f42a36a868d7063aa10ddccf6bd6117a169b6bd00d7ca53999772093b62014f9", size = 91503, upload_time = "2025-04-12T15:30:23.905Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/f7/0aeea75424c47633c1d98557a2323be23bed31fa950f00161b34a5150d06/python_engineio-4.12.0-py3-none-any.whl", hash = "sha256:a0c47c129c39777e8ebc6d18011efd50db2144e4e8f08983acae8a3614626535", size = 59319, upload_time = "2025-04-12T15:30:22.325Z" },
]
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.20" version = "0.0.20"
@ -2014,6 +2052,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" }, { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload_time = "2024-12-16T19:45:44.423Z" },
] ]
[[package]]
name = "python-socketio"
version = "5.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bidict" },
{ name = "python-engineio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125, upload_time = "2025-04-12T15:46:59.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800, upload_time = "2025-04-12T15:46:58.412Z" },
]
[package.optional-dependencies]
client = [
{ name = "requests" },
{ name = "websocket-client" },
]
[[package]] [[package]]
name = "pywin32" name = "pywin32"
version = "306" version = "306"
@ -2226,27 +2283,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.11.6" version = "0.11.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/11/bcef6784c7e5d200b8a1f5c2ddf53e5da0efec37e6e5a44d163fb97e04ba/ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79", size = 4010053, upload_time = "2025-04-17T13:35:53.905Z" } sdist = { url = "https://files.pythonhosted.org/packages/5b/89/6f9c9674818ac2e9cc2f2b35b704b7768656e6b7c139064fc7ba8fbc99f1/ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4", size = 4054861, upload_time = "2025-04-24T18:49:37.007Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/1f/8848b625100ebcc8740c8bac5b5dd8ba97dd4ee210970e98832092c1635b/ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1", size = 10248105, upload_time = "2025-04-17T13:35:14.758Z" }, { url = "https://files.pythonhosted.org/packages/b4/ec/21927cb906c5614b786d1621dba405e3d44f6e473872e6df5d1a6bca0455/ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c", size = 10245403, upload_time = "2025-04-24T18:48:40.459Z" },
{ url = "https://files.pythonhosted.org/packages/e0/47/c44036e70c6cc11e6ee24399c2a1e1f1e99be5152bd7dff0190e4b325b76/ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de", size = 11001494, upload_time = "2025-04-17T13:35:18.444Z" }, { url = "https://files.pythonhosted.org/packages/e2/af/fec85b6c2c725bcb062a354dd7cbc1eed53c33ff3aa665165871c9c16ddf/ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee", size = 11007166, upload_time = "2025-04-24T18:48:44.742Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5b/170444061650202d84d316e8f112de02d092bff71fafe060d3542f5bc5df/ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a", size = 10352151, upload_time = "2025-04-17T13:35:20.563Z" }, { url = "https://files.pythonhosted.org/packages/31/9a/2d0d260a58e81f388800343a45898fd8df73c608b8261c370058b675319a/ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada", size = 10378076, upload_time = "2025-04-24T18:48:47.918Z" },
{ url = "https://files.pythonhosted.org/packages/ff/91/f02839fb3787c678e112c8865f2c3e87cfe1744dcc96ff9fc56cfb97dda2/ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193", size = 10541951, upload_time = "2025-04-17T13:35:22.522Z" }, { url = "https://files.pythonhosted.org/packages/c2/c4/9b09b45051404d2e7dd6d9dbcbabaa5ab0093f9febcae664876a77b9ad53/ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64", size = 10557138, upload_time = "2025-04-24T18:48:51.707Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f3/c09933306096ff7a08abede3cc2534d6fcf5529ccd26504c16bf363989b5/ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e", size = 10079195, upload_time = "2025-04-17T13:35:24.485Z" }, { url = "https://files.pythonhosted.org/packages/5e/5e/f62a1b6669870a591ed7db771c332fabb30f83c967f376b05e7c91bccd14/ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201", size = 10095726, upload_time = "2025-04-24T18:48:54.243Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/a87f8933fccbc0d8c653cfbf44bedda69c9582ba09210a309c066794e2ee/ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308", size = 11698918, upload_time = "2025-04-17T13:35:26.504Z" }, { url = "https://files.pythonhosted.org/packages/45/59/a7aa8e716f4cbe07c3500a391e58c52caf665bb242bf8be42c62adef649c/ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6", size = 11672265, upload_time = "2025-04-24T18:48:57.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/7d/8eac0bd083ea8a0b55b7e4628428203441ca68cd55e0b67c135a4bc6e309/ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55", size = 12319426, upload_time = "2025-04-17T13:35:28.452Z" }, { url = "https://files.pythonhosted.org/packages/dd/e3/101a8b707481f37aca5f0fcc3e42932fa38b51add87bfbd8e41ab14adb24/ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4", size = 12331418, upload_time = "2025-04-24T18:49:00.697Z" },
{ url = "https://files.pythonhosted.org/packages/c2/dc/d0c17d875662d0c86fadcf4ca014ab2001f867621b793d5d7eef01b9dcce/ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc", size = 11791012, upload_time = "2025-04-17T13:35:30.455Z" }, { url = "https://files.pythonhosted.org/packages/dd/71/037f76cbe712f5cbc7b852e4916cd3cf32301a30351818d32ab71580d1c0/ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e", size = 11794506, upload_time = "2025-04-24T18:49:03.545Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f3/81a1aea17f1065449a72509fc7ccc3659cf93148b136ff2a8291c4bc3ef1/ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2", size = 13949947, upload_time = "2025-04-17T13:35:33.133Z" }, { url = "https://files.pythonhosted.org/packages/ca/de/e450b6bab1fc60ef263ef8fcda077fb4977601184877dce1c59109356084/ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63", size = 13939084, upload_time = "2025-04-24T18:49:07.159Z" },
{ url = "https://files.pythonhosted.org/packages/61/9f/a3e34de425a668284e7024ee6fd41f452f6fa9d817f1f3495b46e5e3a407/ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6", size = 11471753, upload_time = "2025-04-17T13:35:35.416Z" }, { url = "https://files.pythonhosted.org/packages/0e/2c/1e364cc92970075d7d04c69c928430b23e43a433f044474f57e425cbed37/ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502", size = 11450441, upload_time = "2025-04-24T18:49:11.41Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/4a57a86d12542c0f6e2744f262257b2aa5a3783098ec14e40f3e4b3a354a/ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2", size = 10417121, upload_time = "2025-04-17T13:35:38.224Z" }, { url = "https://files.pythonhosted.org/packages/9d/7d/1b048eb460517ff9accd78bca0fa6ae61df2b276010538e586f834f5e402/ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92", size = 10441060, upload_time = "2025-04-24T18:49:14.184Z" },
{ url = "https://files.pythonhosted.org/packages/58/3f/a3b4346dff07ef5b862e2ba06d98fcbf71f66f04cf01d375e871382b5e4b/ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03", size = 10073829, upload_time = "2025-04-17T13:35:40.255Z" }, { url = "https://files.pythonhosted.org/packages/3a/57/8dc6ccfd8380e5ca3d13ff7591e8ba46a3b330323515a4996b991b10bd5d/ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94", size = 10058689, upload_time = "2025-04-24T18:49:17.559Z" },
{ url = "https://files.pythonhosted.org/packages/93/cc/7ed02e0b86a649216b845b3ac66ed55d8aa86f5898c5f1691797f408fcb9/ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b", size = 11076108, upload_time = "2025-04-17T13:35:42.559Z" }, { url = "https://files.pythonhosted.org/packages/23/bf/20487561ed72654147817885559ba2aa705272d8b5dee7654d3ef2dbf912/ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6", size = 11073703, upload_time = "2025-04-24T18:49:20.247Z" },
{ url = "https://files.pythonhosted.org/packages/39/5e/5b09840fef0eff1a6fa1dea6296c07d09c17cb6fb94ed5593aa591b50460/ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9", size = 11512366, upload_time = "2025-04-17T13:35:45.702Z" }, { url = "https://files.pythonhosted.org/packages/9d/27/04f2db95f4ef73dccedd0c21daf9991cc3b7f29901a4362057b132075aa4/ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6", size = 11532822, upload_time = "2025-04-24T18:49:23.765Z" },
{ url = "https://files.pythonhosted.org/packages/6f/4c/1cd5a84a412d3626335ae69f5f9de2bb554eea0faf46deb1f0cb48534042/ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287", size = 10485900, upload_time = "2025-04-17T13:35:47.695Z" }, { url = "https://files.pythonhosted.org/packages/e1/72/43b123e4db52144c8add336581de52185097545981ff6e9e58a21861c250/ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26", size = 10362436, upload_time = "2025-04-24T18:49:27.377Z" },
{ url = "https://files.pythonhosted.org/packages/42/46/8997872bc44d43df986491c18d4418f1caff03bc47b7f381261d62c23442/ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e", size = 11558592, upload_time = "2025-04-17T13:35:49.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a0/3e58cd76fdee53d5c8ce7a56d84540833f924ccdf2c7d657cb009e604d82/ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a", size = 11566676, upload_time = "2025-04-24T18:49:30.938Z" },
{ url = "https://files.pythonhosted.org/packages/d7/6a/65fecd51a9ca19e1477c3879a7fda24f8904174d1275b419422ac00f6eee/ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79", size = 10682766, upload_time = "2025-04-17T13:35:52.014Z" }, { url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload_time = "2025-04-24T18:49:34.392Z" },
] ]
[[package]] [[package]]
@ -2349,6 +2406,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/15/88e46eb9387e905704b69849618e699dc2f54407d8953cc4ec4b8b46528d/setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc", size = 931070, upload_time = "2024-07-09T16:07:58.829Z" }, { url = "https://files.pythonhosted.org/packages/ef/15/88e46eb9387e905704b69849618e699dc2f54407d8953cc4ec4b8b46528d/setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc", size = 931070, upload_time = "2024-07-09T16:07:58.829Z" },
] ]
[[package]]
name = "simple-websocket"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload_time = "2024-10-10T22:39:31.412Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload_time = "2024-10-10T22:39:29.645Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
@ -2652,6 +2721,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload_time = "2024-01-06T02:10:55.763Z" }, { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload_time = "2024-01-06T02:10:55.763Z" },
] ]
[[package]]
name = "websocket-client"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload_time = "2024-04-23T22:16:16.976Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload_time = "2024-04-23T22:16:14.422Z" },
]
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "12.0" version = "12.0"
@ -2711,6 +2789,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/6e/e792999e816d19d7fcbfa94c730936750036d65656a76a5a688b57a656c4/werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8", size = 227274, upload_time = "2024-05-05T23:10:29.567Z" }, { url = "https://files.pythonhosted.org/packages/9d/6e/e792999e816d19d7fcbfa94c730936750036d65656a76a5a688b57a656c4/werkzeug-3.0.3-py3-none-any.whl", hash = "sha256:fc9645dc43e03e4d630d23143a04a7f947a9a3b5727cd535fdfe155a17cc48c8", size = 227274, upload_time = "2024-05-05T23:10:29.567Z" },
] ]
[[package]]
name = "wsproto"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload_time = "2022-08-23T19:58:21.447Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload_time = "2022-08-23T19:58:19.96Z" },
]
[[package]] [[package]]
name = "zope-event" name = "zope-event"
version = "5.0" version = "5.0"

View File

@ -8,6 +8,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/services/share.service.dart'; import 'package:immich_mobile/services/share.service.dart';
import 'package:immich_mobile/utils/translation.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart';
@ -57,12 +58,13 @@ Future<void> handleArchiveAssets(
.read(assetProvider.notifier) .read(assetProvider.notifier)
.toggleArchive(selection, shouldArchive); .toggleArchive(selection, shouldArchive);
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset'; final message = shouldArchive
final archiveOrLibrary = shouldArchive ? 'archive' : 'library'; ? t('moved_to_archive', {'count': selection.length})
: t('moved_to_library', {'count': selection.length});
if (context.mounted) { if (context.mounted) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'Moved ${selection.length} $assetOrAssets to $archiveOrLibrary', msg: message,
gravity: toastGravity, gravity: toastGravity,
); );
} }

View File

@ -0,0 +1,14 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:intl/message_format.dart';
String t(String key, [Map<String, Object>? args]) {
try {
String message = key.tr();
if (args != null) {
return MessageFormat(message).format(args);
}
return message;
} catch (e) {
return key;
}
}

View File

@ -377,6 +377,7 @@ Class | Method | HTTP request | Description
- [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md) - [OAuthAuthorizeResponseDto](doc//OAuthAuthorizeResponseDto.md)
- [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md)
- [OnThisDayDto](doc//OnThisDayDto.md) - [OnThisDayDto](doc//OnThisDayDto.md)
- [PartnerDirection](doc//PartnerDirection.md) - [PartnerDirection](doc//PartnerDirection.md)
- [PartnerResponseDto](doc//PartnerResponseDto.md) - [PartnerResponseDto](doc//PartnerResponseDto.md)

View File

@ -178,6 +178,7 @@ part 'model/notification_update_dto.dart';
part 'model/o_auth_authorize_response_dto.dart'; part 'model/o_auth_authorize_response_dto.dart';
part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart'; part 'model/o_auth_config_dto.dart';
part 'model/o_auth_token_endpoint_auth_method.dart';
part 'model/on_this_day_dto.dart'; part 'model/on_this_day_dto.dart';
part 'model/partner_direction.dart'; part 'model/partner_direction.dart';
part 'model/partner_response_dto.dart'; part 'model/partner_response_dto.dart';

View File

@ -410,6 +410,8 @@ class ApiClient {
return OAuthCallbackDto.fromJson(value); return OAuthCallbackDto.fromJson(value);
case 'OAuthConfigDto': case 'OAuthConfigDto':
return OAuthConfigDto.fromJson(value); return OAuthConfigDto.fromJson(value);
case 'OAuthTokenEndpointAuthMethod':
return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value);
case 'OnThisDayDto': case 'OnThisDayDto':
return OnThisDayDto.fromJson(value); return OnThisDayDto.fromJson(value);
case 'PartnerDirection': case 'PartnerDirection':

View File

@ -106,6 +106,9 @@ String parameterToString(dynamic value) {
if (value is NotificationType) { if (value is NotificationType) {
return NotificationTypeTypeTransformer().encode(value).toString(); return NotificationTypeTypeTransformer().encode(value).toString();
} }
if (value is OAuthTokenEndpointAuthMethod) {
return OAuthTokenEndpointAuthMethodTypeTransformer().encode(value).toString();
}
if (value is PartnerDirection) { if (value is PartnerDirection) {
return PartnerDirectionTypeTransformer().encode(value).toString(); return PartnerDirectionTypeTransformer().encode(value).toString();
} }

View File

@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class OAuthTokenEndpointAuthMethod {
/// Instantiate a new enum with the provided [value].
const OAuthTokenEndpointAuthMethod._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const post = OAuthTokenEndpointAuthMethod._(r'client_secret_post');
static const basic = OAuthTokenEndpointAuthMethod._(r'client_secret_basic');
/// List of all possible values in this [enum][OAuthTokenEndpointAuthMethod].
static const values = <OAuthTokenEndpointAuthMethod>[
post,
basic,
];
static OAuthTokenEndpointAuthMethod? fromJson(dynamic value) => OAuthTokenEndpointAuthMethodTypeTransformer().decode(value);
static List<OAuthTokenEndpointAuthMethod> listFromJson(dynamic json, {bool growable = false,}) {
final result = <OAuthTokenEndpointAuthMethod>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = OAuthTokenEndpointAuthMethod.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [OAuthTokenEndpointAuthMethod] to String,
/// and [decode] dynamic data back to [OAuthTokenEndpointAuthMethod].
class OAuthTokenEndpointAuthMethodTypeTransformer {
factory OAuthTokenEndpointAuthMethodTypeTransformer() => _instance ??= const OAuthTokenEndpointAuthMethodTypeTransformer._();
const OAuthTokenEndpointAuthMethodTypeTransformer._();
String encode(OAuthTokenEndpointAuthMethod data) => data.value;
/// Decodes a [dynamic value][data] to a OAuthTokenEndpointAuthMethod.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
OAuthTokenEndpointAuthMethod? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'client_secret_post': return OAuthTokenEndpointAuthMethod.post;
case r'client_secret_basic': return OAuthTokenEndpointAuthMethod.basic;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [OAuthTokenEndpointAuthMethodTypeTransformer] instance.
static OAuthTokenEndpointAuthMethodTypeTransformer? _instance;
}

View File

@ -28,6 +28,8 @@ class SystemConfigOAuthDto {
required this.signingAlgorithm, required this.signingAlgorithm,
required this.storageLabelClaim, required this.storageLabelClaim,
required this.storageQuotaClaim, required this.storageQuotaClaim,
required this.timeout,
required this.tokenEndpointAuthMethod,
}); });
bool autoLaunch; bool autoLaunch;
@ -61,6 +63,11 @@ class SystemConfigOAuthDto {
String storageQuotaClaim; String storageQuotaClaim;
/// Minimum value: 1
int timeout;
OAuthTokenEndpointAuthMethod tokenEndpointAuthMethod;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigOAuthDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigOAuthDto &&
other.autoLaunch == autoLaunch && other.autoLaunch == autoLaunch &&
@ -77,7 +84,9 @@ class SystemConfigOAuthDto {
other.scope == scope && other.scope == scope &&
other.signingAlgorithm == signingAlgorithm && other.signingAlgorithm == signingAlgorithm &&
other.storageLabelClaim == storageLabelClaim && other.storageLabelClaim == storageLabelClaim &&
other.storageQuotaClaim == storageQuotaClaim; other.storageQuotaClaim == storageQuotaClaim &&
other.timeout == timeout &&
other.tokenEndpointAuthMethod == tokenEndpointAuthMethod;
@override @override
int get hashCode => int get hashCode =>
@ -96,10 +105,12 @@ class SystemConfigOAuthDto {
(scope.hashCode) + (scope.hashCode) +
(signingAlgorithm.hashCode) + (signingAlgorithm.hashCode) +
(storageLabelClaim.hashCode) + (storageLabelClaim.hashCode) +
(storageQuotaClaim.hashCode); (storageQuotaClaim.hashCode) +
(timeout.hashCode) +
(tokenEndpointAuthMethod.hashCode);
@override @override
String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim]'; String toString() => 'SystemConfigOAuthDto[autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim, timeout=$timeout, tokenEndpointAuthMethod=$tokenEndpointAuthMethod]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -118,6 +129,8 @@ class SystemConfigOAuthDto {
json[r'signingAlgorithm'] = this.signingAlgorithm; json[r'signingAlgorithm'] = this.signingAlgorithm;
json[r'storageLabelClaim'] = this.storageLabelClaim; json[r'storageLabelClaim'] = this.storageLabelClaim;
json[r'storageQuotaClaim'] = this.storageQuotaClaim; json[r'storageQuotaClaim'] = this.storageQuotaClaim;
json[r'timeout'] = this.timeout;
json[r'tokenEndpointAuthMethod'] = this.tokenEndpointAuthMethod;
return json; return json;
} }
@ -145,6 +158,8 @@ class SystemConfigOAuthDto {
signingAlgorithm: mapValueOfType<String>(json, r'signingAlgorithm')!, signingAlgorithm: mapValueOfType<String>(json, r'signingAlgorithm')!,
storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!, storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!,
storageQuotaClaim: mapValueOfType<String>(json, r'storageQuotaClaim')!, storageQuotaClaim: mapValueOfType<String>(json, r'storageQuotaClaim')!,
timeout: mapValueOfType<int>(json, r'timeout')!,
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.fromJson(json[r'tokenEndpointAuthMethod'])!,
); );
} }
return null; return null;
@ -207,6 +222,8 @@ class SystemConfigOAuthDto {
'signingAlgorithm', 'signingAlgorithm',
'storageLabelClaim', 'storageLabelClaim',
'storageQuotaClaim', 'storageQuotaClaim',
'timeout',
'tokenEndpointAuthMethod',
}; };
} }

View File

@ -10824,6 +10824,13 @@
], ],
"type": "object" "type": "object"
}, },
"OAuthTokenEndpointAuthMethod": {
"enum": [
"client_secret_post",
"client_secret_basic"
],
"type": "string"
},
"OnThisDayDto": { "OnThisDayDto": {
"properties": { "properties": {
"year": { "year": {
@ -13404,6 +13411,17 @@
}, },
"storageQuotaClaim": { "storageQuotaClaim": {
"type": "string" "type": "string"
},
"timeout": {
"minimum": 1,
"type": "integer"
},
"tokenEndpointAuthMethod": {
"allOf": [
{
"$ref": "#/components/schemas/OAuthTokenEndpointAuthMethod"
}
]
} }
}, },
"required": [ "required": [
@ -13421,7 +13439,9 @@
"scope", "scope",
"signingAlgorithm", "signingAlgorithm",
"storageLabelClaim", "storageLabelClaim",
"storageQuotaClaim" "storageQuotaClaim",
"timeout",
"tokenEndpointAuthMethod"
], ],
"type": "object" "type": "object"
}, },

View File

@ -1315,6 +1315,8 @@ export type SystemConfigOAuthDto = {
signingAlgorithm: string; signingAlgorithm: string;
storageLabelClaim: string; storageLabelClaim: string;
storageQuotaClaim: string; storageQuotaClaim: string;
timeout: number;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
}; };
export type SystemConfigPasswordLoginDto = { export type SystemConfigPasswordLoginDto = {
enabled: boolean; enabled: boolean;
@ -3859,6 +3861,10 @@ export enum LogLevel {
Error = "error", Error = "error",
Fatal = "fatal" Fatal = "fatal"
} }
export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum TimeBucketSize { export enum TimeBucketSize {
Day = "DAY", Day = "DAY",
Month = "MONTH" Month = "MONTH"

View File

@ -26,7 +26,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build # web build
FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS web FROM node:22.15.0-alpine3.20@sha256:686b8892b69879ef5bfd6047589666933508f9a5451c67320df3070ba0e9807b AS web
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

578
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ import {
CQMode, CQMode,
ImageFormat, ImageFormat,
LogLevel, LogLevel,
OAuthTokenEndpointAuthMethod,
QueueName, QueueName,
ToneMapping, ToneMapping,
TranscodeHWAccel, TranscodeHWAccel,
@ -96,6 +97,8 @@ export interface SystemConfig {
scope: string; scope: string;
signingAlgorithm: string; signingAlgorithm: string;
profileSigningAlgorithm: string; profileSigningAlgorithm: string;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
timeout: number;
storageLabelClaim: string; storageLabelClaim: string;
storageQuotaClaim: string; storageQuotaClaim: string;
}; };
@ -260,6 +263,8 @@ export const defaults = Object.freeze<SystemConfig>({
profileSigningAlgorithm: 'none', profileSigningAlgorithm: 'none',
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota', storageQuotaClaim: 'immich_quota',
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST,
timeout: 30_000,
}, },
passwordLogin: { passwordLogin: {
enabled: true, enabled: true,

View File

@ -25,6 +25,7 @@ import {
Colorspace, Colorspace,
ImageFormat, ImageFormat,
LogLevel, LogLevel,
OAuthTokenEndpointAuthMethod,
QueueName, QueueName,
ToneMapping, ToneMapping,
TranscodeHWAccel, TranscodeHWAccel,
@ -33,7 +34,7 @@ import {
VideoContainer, VideoContainer,
} from 'src/enum'; } from 'src/enum';
import { ConcurrentQueueName } from 'src/types'; import { ConcurrentQueueName } from 'src/types';
import { IsCronExpression, ValidateBoolean } from 'src/validation'; import { IsCronExpression, Optional, ValidateBoolean } from 'src/validation';
const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled; const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled; const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
@ -344,10 +345,19 @@ class SystemConfigOAuthDto {
clientId!: string; clientId!: string;
@ValidateIf(isOAuthEnabled) @ValidateIf(isOAuthEnabled)
@IsNotEmpty()
@IsString() @IsString()
clientSecret!: string; clientSecret!: string;
@IsEnum(OAuthTokenEndpointAuthMethod)
@ApiProperty({ enum: OAuthTokenEndpointAuthMethod, enumName: 'OAuthTokenEndpointAuthMethod' })
tokenEndpointAuthMethod!: OAuthTokenEndpointAuthMethod;
@IsInt()
@IsPositive()
@Optional()
@ApiProperty({ type: 'integer' })
timeout!: number;
@IsNumber() @IsNumber()
@Min(0) @Min(0)
defaultStorageQuota!: number; defaultStorageQuota!: number;

View File

@ -605,3 +605,8 @@ export enum NotificationType {
SystemMessage = 'SystemMessage', SystemMessage = 'SystemMessage',
Custom = 'Custom', Custom = 'Custom',
} }
export enum OAuthTokenEndpointAuthMethod {
CLIENT_SECRET_POST = 'client_secret_post',
CLIENT_SECRET_BASIC = 'client_secret_basic',
}

View File

@ -1,5 +1,5 @@
import { DatabaseExtension } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { vectorIndexQuery } from 'src/utils/database';
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
@ -8,15 +8,9 @@ export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface {
name = 'AddCLIPEmbeddingIndex1700713994428'; name = 'AddCLIPEmbeddingIndex1700713994428';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
if (vectorExtension === DatabaseExtension.VECTORS) {
await queryRunner.query(`SET vectors.pgvector_compatibility=on`);
}
await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET search_path TO "$user", public, vectors`);
await queryRunner.query(` await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
CREATE INDEX IF NOT EXISTS clip_index ON smart_search
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {

View File

@ -1,5 +1,5 @@
import { DatabaseExtension } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { vectorIndexQuery } from 'src/utils/database';
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
@ -8,15 +8,9 @@ export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface {
name = 'AddFaceEmbeddingIndex1700714033632'; name = 'AddFaceEmbeddingIndex1700714033632';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
if (vectorExtension === DatabaseExtension.VECTORS) {
await queryRunner.query(`SET vectors.pgvector_compatibility=on`);
}
await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET search_path TO "$user", public, vectors`);
await queryRunner.query(` await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' }));
CREATE INDEX IF NOT EXISTS face_index ON asset_faces
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {

View File

@ -1,5 +1,6 @@
import { DatabaseExtension } from 'src/enum'; import { DatabaseExtension } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { vectorIndexQuery } from 'src/utils/database';
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
@ -8,7 +9,6 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
if (vectorExtension === DatabaseExtension.VECTORS) { if (vectorExtension === DatabaseExtension.VECTORS) {
await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET search_path TO "$user", public, vectors`);
await queryRunner.query(`SET vectors.pgvector_compatibility=on`);
} }
const hasEmbeddings = async (tableName: string): Promise<boolean> => { const hasEmbeddings = async (tableName: string): Promise<boolean> => {
@ -47,21 +47,14 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE real[]`); await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE real[]`);
await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512)`); await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET DATA TYPE vector(512)`);
await queryRunner.query(` await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' }));
CREATE INDEX IF NOT EXISTS clip_index ON smart_search
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`);
await queryRunner.query(` await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' }));
CREATE INDEX face_index ON face_search
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
if (vectorExtension === DatabaseExtension.VECTORS) { if (vectorExtension === DatabaseExtension.VECTORS) {
await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET search_path TO "$user", public, vectors`);
await queryRunner.query(`SET vectors.pgvector_compatibility=on`);
} }
await queryRunner.query(`ALTER TABLE asset_faces ADD COLUMN "embedding" vector(512)`); await queryRunner.query(`ALTER TABLE asset_faces ADD COLUMN "embedding" vector(512)`);
@ -74,9 +67,6 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface {
WHERE id = fs."faceId"`); WHERE id = fs."faceId"`);
await queryRunner.query(`DROP TABLE face_search`); await queryRunner.query(`DROP TABLE face_search`);
await queryRunner.query(` await queryRunner.query(vectorIndexQuery({ vectorExtension, table: 'asset_faces', indexName: 'face_index' }));
CREATE INDEX face_index ON asset_faces
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`);
} }
} }

View File

@ -194,6 +194,24 @@ where
"asset_files"."assetId" = $1 "asset_files"."assetId" = $1
and "asset_files"."type" = $2 and "asset_files"."type" = $2
-- AssetJobRepository.streamForEncodeClip
select
"assets"."id"
from
"assets"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where
"job_status"."previewAt" is not null
and "assets"."isVisible" = $1
and not exists (
select
from
"smart_search"
where
"assetId" = "assets"."id"
)
and "assets"."deletedAt" is null
-- AssetJobRepository.getForClipEncoding -- AssetJobRepository.getForClipEncoding
select select
"assets"."id", "assets"."id",

View File

@ -135,6 +135,23 @@ export class AssetJobRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [], stream: true })
streamForEncodeClip(force?: boolean) {
return this.db
.selectFrom('assets')
.select(['assets.id'])
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('job_status.previewAt', 'is not', null)
.where('assets.isVisible', '=', true)
.$if(!force, (qb) =>
qb.where((eb) =>
eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))),
),
)
.where('assets.deletedAt', 'is', null)
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getForClipEncoding(id: string) { getForClipEncoding(id: string) {
return this.db return this.db

View File

@ -49,7 +49,6 @@ export enum WithoutProperty {
THUMBNAIL = 'thumbnail', THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded-video', ENCODED_VIDEO = 'encoded-video',
EXIF = 'exif', EXIF = 'exif',
SMART_SEARCH = 'smart-search',
DUPLICATE = 'duplicate', DUPLICATE = 'duplicate',
FACES = 'faces', FACES = 'faces',
SIDECAR = 'sidecar', SIDECAR = 'sidecar',
@ -571,15 +570,6 @@ export class AssetRepository {
.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])) .where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)]))
.where('assets.isVisible', '=', true), .where('assets.isVisible', '=', true),
) )
.$if(property === WithoutProperty.SMART_SEARCH, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('job_status.previewAt', 'is not', null)
.where('assets.isVisible', '=', true)
.where((eb) =>
eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))),
),
)
.$if(property === WithoutProperty.THUMBNAIL, (qb) => .$if(property === WithoutProperty.THUMBNAIL, (qb) =>
qb qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id') .innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')

View File

@ -12,6 +12,7 @@ import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
import { vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
@ -119,12 +120,7 @@ export class DatabaseRepository {
await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute( await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE vector(${sql.raw(String(dimSize))})`.execute(
tx, tx,
); );
await sql`SET vectors.pgvector_compatibility=on`.execute(tx); await sql.raw(vectorIndexQuery({ vectorExtension: this.vectorExtension, table, indexName: index })).execute(tx);
await sql`
CREATE INDEX IF NOT EXISTS ${sql.raw(index)} ON ${sql.raw(table)}
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)
`.execute(tx);
}); });
} }
} }

View File

@ -5,7 +5,7 @@ import { Telemetry } from 'src/decorators';
import { LogLevel } from 'src/enum'; import { LogLevel } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
type LogDetails = any[]; type LogDetails = any;
type LogFunction = () => string; type LogFunction = () => string;
const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL];

View File

@ -1,16 +1,19 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' }; import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' };
import { OAuthTokenEndpointAuthMethod } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
export type OAuthConfig = { export type OAuthConfig = {
clientId: string; clientId: string;
clientSecret: string; clientSecret?: string;
issuerUrl: string; issuerUrl: string;
mobileOverrideEnabled: boolean; mobileOverrideEnabled: boolean;
mobileRedirectUri: string; mobileRedirectUri: string;
profileSigningAlgorithm: string; profileSigningAlgorithm: string;
scope: string; scope: string;
signingAlgorithm: string; signingAlgorithm: string;
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod;
timeout: number;
}; };
export type OAuthProfile = UserInfoResponse; export type OAuthProfile = UserInfoResponse;
@ -76,12 +79,10 @@ export class OAuthRepository {
); );
} }
if (error.code === 'OAUTH_INVALID_RESPONSE') { this.logger.error(`OAuth login failed: ${error.message}`);
this.logger.warn(`Invalid response from authorization server. Cause: ${error.cause?.message}`); this.logger.error(error);
throw error.cause;
}
throw error; throw new Error('OAuth login failed', { cause: error });
} }
} }
@ -103,6 +104,8 @@ export class OAuthRepository {
clientSecret, clientSecret,
profileSigningAlgorithm, profileSigningAlgorithm,
signingAlgorithm, signingAlgorithm,
tokenEndpointAuthMethod,
timeout,
}: OAuthConfig) { }: OAuthConfig) {
try { try {
const { allowInsecureRequests, discovery } = await import('openid-client'); const { allowInsecureRequests, discovery } = await import('openid-client');
@ -114,14 +117,38 @@ export class OAuthRepository {
response_types: ['code'], response_types: ['code'],
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm, userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
id_token_signed_response_alg: signingAlgorithm, id_token_signed_response_alg: signingAlgorithm,
timeout: 30_000,
}, },
undefined, await this.getTokenAuthMethod(tokenEndpointAuthMethod, clientSecret),
{ execute: [allowInsecureRequests] }, {
execute: [allowInsecureRequests],
timeout,
},
); );
} catch (error: any | AggregateError) { } catch (error: any | AggregateError) {
this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors); this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors);
throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error }); throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error });
} }
} }
private async getTokenAuthMethod(tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod, clientSecret?: string) {
const { None, ClientSecretPost, ClientSecretBasic } = await import('openid-client');
if (!clientSecret) {
return None();
}
switch (tokenEndpointAuthMethod) {
case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST: {
return ClientSecretPost(clientSecret);
}
case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_BASIC: {
return ClientSecretBasic(clientSecret);
}
default: {
return None();
}
}
}
} }

View File

@ -6,7 +6,8 @@ import { DB, Exif } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetStatus, AssetType } from 'src/enum'; import { AssetStatus, AssetType } from 'src/enum';
import { anyUuid, asUuid, searchAssetBuilder } from 'src/utils/database'; import { ConfigRepository } from 'src/repositories/config.repository';
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
export interface SearchResult<T> { export interface SearchResult<T> {
@ -201,7 +202,10 @@ export interface GetCameraMakesOptions {
@Injectable() @Injectable()
export class SearchRepository { export class SearchRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(
@InjectKysely() private db: Kysely<DB>,
private configRepository: ConfigRepository,
) {}
@GenerateSql({ @GenerateSql({
params: [ params: [
@ -446,8 +450,8 @@ export class SearchRepository {
async upsert(assetId: string, embedding: string): Promise<void> { async upsert(assetId: string, embedding: string): Promise<void> {
await this.db await this.db
.insertInto('smart_search') .insertInto('smart_search')
.values({ assetId: asUuid(assetId), embedding } as any) .values({ assetId, embedding })
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding } as any)) .onConflict((oc) => oc.column('assetId').doUpdateSet((eb) => ({ embedding: eb.ref('excluded.embedding') })))
.execute(); .execute();
} }
@ -469,19 +473,32 @@ export class SearchRepository {
return dimSize; return dimSize;
} }
setDimensionSize(dimSize: number): Promise<void> { async setDimensionSize(dimSize: number): Promise<void> {
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
throw new Error(`Invalid CLIP dimension size: ${dimSize}`); throw new Error(`Invalid CLIP dimension size: ${dimSize}`);
} }
return this.db.transaction().execute(async (trx) => { // this is done in two transactions to handle concurrent writes
await sql`truncate ${sql.table('smart_search')}`.execute(trx); await this.db.transaction().execute(async (trx) => {
await sql`delete from ${sql.table('smart_search')}`.execute(trx);
await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
await sql`alter table ${sql.table('smart_search')} add constraint dim_size_constraint check (array_length(embedding::real[], 1) = ${sql.lit(dimSize)})`.execute(
trx,
);
});
const vectorExtension = this.configRepository.getEnv().database.vectorExtension;
await this.db.transaction().execute(async (trx) => {
await sql`drop index if exists clip_index`.execute(trx);
await trx.schema await trx.schema
.alterTable('smart_search') .alterTable('smart_search')
.alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`))) .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`)))
.execute(); .execute();
await sql`reindex index clip_index`.execute(trx); await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(trx);
await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute();
}); });
await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db);
} }
async deleteAllSearchEmbeddings(): Promise<void> { async deleteAllSearchEmbeddings(): Promise<void> {

View File

@ -47,14 +47,8 @@ import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools'; import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'vectors', 'plpgsql']) @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' }) @ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })
@ConfigurationParameter({
name: 'vectors.pgvector_compatibility',
value: () => 'on',
scope: 'user',
synchronize: false,
})
@Database({ name: 'immich' }) @Database({ name: 'immich' })
export class ImmichDatabase { export class ImmichDatabase {
tables = [ tables = [

View File

@ -2,6 +2,7 @@ import { Kysely, sql } from 'kysely';
import { DatabaseExtension } from 'src/enum'; import { DatabaseExtension } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { vectorIndexQuery } from 'src/utils/database';
const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension;
const lastMigrationSql = sql<{ name: string }>`SELECT "name" FROM "migrations" ORDER BY "timestamp" DESC LIMIT 1;`; const lastMigrationSql = sql<{ name: string }>`SELECT "name" FROM "migrations" ORDER BY "timestamp" DESC LIMIT 1;`;
@ -29,7 +30,7 @@ export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE EXTENSION IF NOT EXISTS "cube";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "cube";`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "earthdistance";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "earthdistance";`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS "pg_trgm";`.execute(db);
await sql`CREATE EXTENSION IF NOT EXISTS "vectors";`.execute(db); await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(vectorExtension)}`.execute(db);
await sql`CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp()) await sql`CREATE OR REPLACE FUNCTION immich_uuid_v7(p_timestamp timestamp with time zone default clock_timestamp())
RETURNS uuid RETURNS uuid
VOLATILE LANGUAGE SQL VOLATILE LANGUAGE SQL
@ -108,7 +109,6 @@ export async function up(db: Kysely<any>): Promise<void> {
$$;`.execute(db); $$;`.execute(db);
if (vectorExtension === DatabaseExtension.VECTORS) { if (vectorExtension === DatabaseExtension.VECTORS) {
await sql`SET search_path TO "$user", public, vectors`.execute(db); await sql`SET search_path TO "$user", public, vectors`.execute(db);
await sql`SET vectors.pgvector_compatibility=on`.execute(db);
} }
await sql`CREATE TYPE "assets_status_enum" AS ENUM ('active','trashed','deleted');`.execute(db); await sql`CREATE TYPE "assets_status_enum" AS ENUM ('active','trashed','deleted');`.execute(db);
await sql`CREATE TYPE "sourcetype" AS ENUM ('machine-learning','exif','manual');`.execute(db); await sql`CREATE TYPE "sourcetype" AS ENUM ('machine-learning','exif','manual');`.execute(db);
@ -293,7 +293,7 @@ export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE INDEX "IDX_live_photo_cid" ON "exif" ("livePhotoCID")`.execute(db); await sql`CREATE INDEX "IDX_live_photo_cid" ON "exif" ("livePhotoCID")`.execute(db);
await sql`CREATE INDEX "IDX_auto_stack_id" ON "exif" ("autoStackId")`.execute(db); await sql`CREATE INDEX "IDX_auto_stack_id" ON "exif" ("autoStackId")`.execute(db);
await sql`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId")`.execute(db); await sql`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId")`.execute(db);
await sql`CREATE INDEX "face_index" ON "face_search" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16)`.execute(db); await sql.raw(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' })).execute(db);
await sql`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" (ll_to_earth_public(latitude, longitude))`.execute(db); await sql`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" (ll_to_earth_public(latitude, longitude))`.execute(db);
await sql`CREATE INDEX "idx_geodata_places_name" ON "geodata_places" USING gin (f_unaccent("name") gin_trgm_ops)`.execute(db); await sql`CREATE INDEX "idx_geodata_places_name" ON "geodata_places" USING gin (f_unaccent("name") gin_trgm_ops)`.execute(db);
await sql`CREATE INDEX "idx_geodata_places_admin2_name" ON "geodata_places" USING gin (f_unaccent("admin2Name") gin_trgm_ops)`.execute(db); await sql`CREATE INDEX "idx_geodata_places_admin2_name" ON "geodata_places" USING gin (f_unaccent("admin2Name") gin_trgm_ops)`.execute(db);
@ -316,7 +316,7 @@ export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE INDEX "IDX_sharedlink_albumId" ON "shared_links" ("albumId")`.execute(db); await sql`CREATE INDEX "IDX_sharedlink_albumId" ON "shared_links" ("albumId")`.execute(db);
await sql`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId")`.execute(db); await sql`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId")`.execute(db);
await sql`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId")`.execute(db); await sql`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId")`.execute(db);
await sql`CREATE INDEX "clip_index" ON "smart_search" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16)`.execute(db); await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(db);
await sql`CREATE INDEX "IDX_d8ddd9d687816cc490432b3d4b" ON "session_sync_checkpoints" ("sessionId")`.execute(db); await sql`CREATE INDEX "IDX_d8ddd9d687816cc490432b3d4b" ON "session_sync_checkpoints" ("sessionId")`.execute(db);
await sql`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`.execute(db); await sql`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`.execute(db);
await sql`CREATE INDEX "IDX_92e67dc508c705dd66c9461557" ON "tags" ("userId")`.execute(db); await sql`CREATE INDEX "IDX_92e67dc508c705dd66c9461557" ON "tags" ("userId")`.execute(db);

View File

@ -1,11 +1,10 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { ImmichWorker, JobName, JobStatus } from 'src/enum'; import { ImmichWorker, JobName, JobStatus } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { SmartInfoService } from 'src/services/smart-info.service'; import { SmartInfoService } from 'src/services/smart-info.service';
import { getCLIPModelInfo } from 'src/utils/misc'; import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
describe(SmartInfoService.name, () => { describe(SmartInfoService.name, () => {
let sut: SmartInfoService; let sut: SmartInfoService;
@ -58,10 +57,6 @@ describe(SmartInfoService.name, () => {
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled();
}); });
it('should return if model and DB dimension size are equal', async () => { it('should return if model and DB dimension size are equal', async () => {
@ -72,38 +67,15 @@ describe(SmartInfoService.name, () => {
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled();
}); });
it('should update DB dimension size if model and DB have different values', async () => { it('should update DB dimension size if model and DB have different values', async () => {
mocks.search.getDimensionSize.mockResolvedValue(768); mocks.search.getDimensionSize.mockResolvedValue(768);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig }); await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512); expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512);
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).toHaveBeenCalledTimes(1);
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).toHaveBeenCalledTimes(1);
});
it('should skip pausing and resuming queue if already paused', async () => {
mocks.search.getDimensionSize.mockResolvedValue(768);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
await sut.onConfigInit({ newConfig: systemConfigStub.machineLearningEnabled as SystemConfig });
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(512);
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).not.toHaveBeenCalled();
}); });
}); });
@ -120,10 +92,6 @@ describe(SmartInfoService.name, () => {
expect(mocks.search.getDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.getDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled();
}); });
it('should return if model and DB dimension size are equal', async () => { it('should return if model and DB dimension size are equal', async () => {
@ -141,15 +109,10 @@ describe(SmartInfoService.name, () => {
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).not.toHaveBeenCalled();
expect(mocks.job.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).not.toHaveBeenCalled();
expect(mocks.job.resume).not.toHaveBeenCalled();
}); });
it('should update DB dimension size if model and DB have different values', async () => { it('should update DB dimension size if model and DB have different values', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512); mocks.search.getDimensionSize.mockResolvedValue(512);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigUpdate({ await sut.onConfigUpdate({
newConfig: { newConfig: {
@ -162,15 +125,10 @@ describe(SmartInfoService.name, () => {
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768); expect(mocks.search.setDimensionSize).toHaveBeenCalledWith(768);
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).toHaveBeenCalledTimes(1);
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).toHaveBeenCalledTimes(1);
}); });
it('should clear embeddings if old and new models are different', async () => { it('should clear embeddings if old and new models are different', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512); mocks.search.getDimensionSize.mockResolvedValue(512);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
await sut.onConfigUpdate({ await sut.onConfigUpdate({
newConfig: { newConfig: {
@ -184,31 +142,6 @@ describe(SmartInfoService.name, () => {
expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled();
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1); expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).toHaveBeenCalledTimes(1);
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).toHaveBeenCalledTimes(1);
});
it('should skip pausing and resuming queue if already paused', async () => {
mocks.search.getDimensionSize.mockResolvedValue(512);
mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true });
await sut.onConfigUpdate({
newConfig: {
machineLearning: { clip: { modelName: 'ViT-B-32__openai', enabled: true }, enabled: true },
} as SystemConfig,
oldConfig: {
machineLearning: { clip: { modelName: 'ViT-B-16__openai', enabled: true }, enabled: true },
} as SystemConfig,
});
expect(mocks.search.getDimensionSize).toHaveBeenCalledTimes(1);
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
expect(mocks.job.getQueueStatus).toHaveBeenCalledTimes(1);
expect(mocks.job.pause).not.toHaveBeenCalled();
expect(mocks.job.waitForQueueCompletion).toHaveBeenCalledTimes(1);
expect(mocks.job.resume).not.toHaveBeenCalled();
}); });
}); });
@ -218,38 +151,32 @@ describe(SmartInfoService.name, () => {
await sut.handleQueueEncodeClip({}); await sut.handleQueueEncodeClip({});
expect(mocks.asset.getAll).not.toHaveBeenCalled();
expect(mocks.asset.getWithout).not.toHaveBeenCalled(); expect(mocks.asset.getWithout).not.toHaveBeenCalled();
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
}); });
it('should queue the assets without clip embeddings', async () => { it('should queue the assets without clip embeddings', async () => {
mocks.asset.getWithout.mockResolvedValue({ mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueEncodeClip({ force: false }); await sut.handleQueueEncodeClip({ force: false });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
]); ]);
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false);
expect(mocks.search.deleteAllSearchEmbeddings).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
}); });
it('should queue all the assets', async () => { it('should queue all the assets', async () => {
mocks.asset.getAll.mockResolvedValue({ mocks.assetJob.streamForEncodeClip.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueEncodeClip({ force: true }); await sut.handleQueueEncodeClip({ force: true });
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } },
]); ]);
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true);
expect(mocks.search.deleteAllSearchEmbeddings).toHaveBeenCalled(); expect(mocks.search.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512);
}); });
}); });

View File

@ -3,12 +3,10 @@ import { SystemConfig } from 'src/config';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class SmartInfoService extends BaseService { export class SmartInfoService extends BaseService {
@ -50,12 +48,6 @@ export class SmartInfoService extends BaseService {
return; return;
} }
const { isPaused } = await this.jobRepository.getQueueStatus(QueueName.SMART_SEARCH);
if (!isPaused) {
await this.jobRepository.pause(QueueName.SMART_SEARCH);
}
await this.jobRepository.waitForQueueCompletion(QueueName.SMART_SEARCH);
if (dimSizeChange) { if (dimSizeChange) {
this.logger.log( this.logger.log(
`Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`, `Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`,
@ -67,9 +59,8 @@ export class SmartInfoService extends BaseService {
await this.searchRepository.deleteAllSearchEmbeddings(); await this.searchRepository.deleteAllSearchEmbeddings();
} }
if (!isPaused) { // TODO: A job to reindex all assets should be scheduled, though user
await this.jobRepository.resume(QueueName.SMART_SEARCH); // confirmation should probably be requested before doing that.
}
}); });
} }
@ -81,20 +72,22 @@ export class SmartInfoService extends BaseService {
} }
if (force) { if (force) {
await this.searchRepository.deleteAllSearchEmbeddings(); const { dimSize } = getCLIPModelInfo(machineLearning.clip.modelName);
// in addition to deleting embeddings, update the dimension size in case it failed earlier
await this.searchRepository.setDimensionSize(dimSize);
} }
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { let queue: JobItem[] = [];
return force const assets = this.assetJobRepository.streamForEncodeClip(force);
? this.assetRepository.getAll(pagination, { isVisible: true }) for await (const asset of assets) {
: this.assetRepository.getWithout(pagination, WithoutProperty.SMART_SEARCH); queue.push({ name: JobName.SMART_SEARCH, data: { id: asset.id } });
}); if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(queue);
for await (const assets of assetPagination) { queue = [];
await this.jobRepository.queueAll(
assets.map((asset) => ({ name: JobName.SMART_SEARCH, data: { id: asset.id } })),
);
} }
}
await this.jobRepository.queueAll(queue);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@ -126,6 +119,12 @@ export class SmartInfoService extends BaseService {
await this.databaseRepository.wait(DatabaseLock.CLIPDimSize); await this.databaseRepository.wait(DatabaseLock.CLIPDimSize);
} }
const newConfig = await this.getConfig({ withCache: true });
if (machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName) {
// Skip the job if the the model has changed since the embedding was generated.
return JobStatus.SKIPPED;
}
await this.searchRepository.upsert(asset.id, embedding); await this.searchRepository.upsert(asset.id, embedding);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;

View File

@ -6,6 +6,7 @@ import {
CQMode, CQMode,
ImageFormat, ImageFormat,
LogLevel, LogLevel,
OAuthTokenEndpointAuthMethod,
QueueName, QueueName,
ToneMapping, ToneMapping,
TranscodeHWAccel, TranscodeHWAccel,
@ -119,6 +120,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
scope: 'openid email profile', scope: 'openid email profile',
signingAlgorithm: 'RS256', signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none', profileSigningAlgorithm: 'none',
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST,
timeout: 30_000,
storageLabelClaim: 'preferred_username', storageLabelClaim: 'preferred_username',
storageQuotaClaim: 'immich_quota', storageQuotaClaim: 'immich_quota',
}, },

View File

@ -17,10 +17,10 @@ import { parse } from 'pg-connection-string';
import postgres, { Notice } from 'postgres'; import postgres, { Notice } from 'postgres';
import { columns, Exif, Person } from 'src/database'; import { columns, Exif, Person } from 'src/database';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { AssetFileType } from 'src/enum'; import { AssetFileType, DatabaseExtension } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DatabaseConnectionParams } from 'src/types'; import { DatabaseConnectionParams, VectorExtension } from 'src/types';
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
@ -373,3 +373,28 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople))
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)); .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
} }
type VectorIndexOptions = { vectorExtension: VectorExtension; table: string; indexName: string };
export function vectorIndexQuery({ vectorExtension, table, indexName }: VectorIndexOptions): string {
switch (vectorExtension) {
case DatabaseExtension.VECTORS: {
return `
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
USING vectors (embedding vector_cos_ops) WITH (options = $$
[indexing.hnsw]
m = 16
ef_construction = 300
$$)`;
}
case DatabaseExtension.VECTOR: {
return `
CREATE INDEX IF NOT EXISTS ${indexName} ON ${table}
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 300, m = 16)`;
}
default: {
throw new Error(`Unsupported vector extension: '${vectorExtension}'`);
}
}
}

View File

@ -173,7 +173,7 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
} }
case 'search': { case 'search': {
return new SearchRepository(db); return new SearchRepository(db, new ConfigRepository());
} }
case 'session': { case 'session': {

View File

@ -1,4 +1,4 @@
FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 FROM node:22.15.0-alpine3.20@sha256:686b8892b69879ef5bfd6047589666933508f9a5451c67320df3070ba0e9807b
RUN apk add --no-cache tini RUN apk add --no-cache tini
USER node USER node

233
web/package-lock.json generated
View File

@ -2443,17 +2443,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz",
"integrity": "sha512-v+VWphxMjn+1t48/jO4t950D6KR8JaJuNXzi33Ve6P8sEmPr5k6CEXjdGwT6+LodVnEa91EQCtwjWNUCPweo+Q==", "integrity": "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/scope-manager": "8.31.0",
"@typescript-eslint/type-utils": "8.30.1", "@typescript-eslint/type-utils": "8.31.0",
"@typescript-eslint/utils": "8.30.1", "@typescript-eslint/utils": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1", "@typescript-eslint/visitor-keys": "8.31.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.3.1", "ignore": "^5.3.1",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -2473,16 +2473,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.0.tgz",
"integrity": "sha512-H+vqmWwT5xoNrXqWs/fesmssOW70gxFlgcMlYcBaWNPIEWDgLa4W9nkSPmhuOgLnXq9QYgkZ31fhDyLhleCsAg==", "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/scope-manager": "8.31.0",
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/typescript-estree": "8.30.1", "@typescript-eslint/typescript-estree": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1", "@typescript-eslint/visitor-keys": "8.31.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -2498,14 +2498,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.0.tgz",
"integrity": "sha512-+C0B6ChFXZkuaNDl73FJxRYT0G7ufVPOSQkqkpM/U198wUwUFOtgo1k/QzFh1KjpBitaK7R1tgjVz6o9HmsRPg==", "integrity": "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1" "@typescript-eslint/visitor-keys": "8.31.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2516,14 +2516,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.0.tgz",
"integrity": "sha512-64uBF76bfQiJyHgZISC7vcNz3adqQKIccVoKubyQcOnNcdJBvYOILV1v22Qhsw3tw3VQu5ll8ND6hycgAR5fEA==", "integrity": "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "8.30.1", "@typescript-eslint/typescript-estree": "8.31.0",
"@typescript-eslint/utils": "8.30.1", "@typescript-eslint/utils": "8.31.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.0.1" "ts-api-utils": "^2.0.1"
}, },
@ -2540,9 +2540,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.0.tgz",
"integrity": "sha512-81KawPfkuulyWo5QdyG/LOKbspyyiW+p4vpn4bYO7DM/hZImlVnFwrpCTnmNMOt8CvLRr5ojI9nU1Ekpw4RcEw==", "integrity": "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2554,14 +2554,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.0.tgz",
"integrity": "sha512-kQQnxymiUy9tTb1F2uep9W6aBiYODgq5EMSk6Nxh4Z+BDUoYUSa029ISs5zTzKBFnexQEh71KqwjKnRz58lusQ==", "integrity": "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/visitor-keys": "8.30.1", "@typescript-eslint/visitor-keys": "8.31.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -2607,16 +2607,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.0.tgz",
"integrity": "sha512-T/8q4R9En2tcEsWPQgB5BQ0XJVOtfARcUvOa8yJP3fh9M/mXraLxZrkCfGb6ChrO/V3W+Xbd04RacUEqk1CFEQ==", "integrity": "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.30.1", "@typescript-eslint/scope-manager": "8.31.0",
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"@typescript-eslint/typescript-estree": "8.30.1" "@typescript-eslint/typescript-estree": "8.31.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2631,13 +2631,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.30.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.0.tgz",
"integrity": "sha512-aEhgas7aJ6vZnNFC7K4/vMGDGyOiqWcYZPpIWrTKuTAlsvDNKy2GFDqh9smL+iq069ZvR0YzEeq0B8NJlLzjFA==", "integrity": "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.30.1", "@typescript-eslint/types": "8.31.0",
"eslint-visitor-keys": "^4.2.0" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
@ -2649,9 +2649,9 @@
} }
}, },
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.2.tgz",
"integrity": "sha512-MgV6D2dhpD6Hp/uroUoAIvFqA8AuvXEFBC2eepG3WFc1pxTfdk1LEqqkWoWhjz+rytoqrnUUCdf6Lzco3iHkLQ==", "integrity": "sha512-XDdaDOeaTMAMYW7N63AqoK32sYUWbXnTkC6tEbVcu3RlU1bB9of32T+PGf8KZvxqLNqeXhafDFqCkwpf2+dyaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2664,7 +2664,7 @@
"istanbul-reports": "^3.1.7", "istanbul-reports": "^3.1.7",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"magicast": "^0.3.5", "magicast": "^0.3.5",
"std-env": "^3.8.1", "std-env": "^3.9.0",
"test-exclude": "^7.0.1", "test-exclude": "^7.0.1",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@ -2672,8 +2672,8 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
}, },
"peerDependencies": { "peerDependencies": {
"@vitest/browser": "3.1.1", "@vitest/browser": "3.1.2",
"vitest": "3.1.1" "vitest": "3.1.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@vitest/browser": { "@vitest/browser": {
@ -2682,14 +2682,14 @@
} }
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.2.tgz",
"integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", "integrity": "sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.1.1", "@vitest/spy": "3.1.2",
"@vitest/utils": "3.1.1", "@vitest/utils": "3.1.2",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@ -2698,13 +2698,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.2.tgz",
"integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", "integrity": "sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.1.1", "@vitest/spy": "3.1.2",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
@ -2735,9 +2735,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz",
"integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2748,13 +2748,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.2.tgz",
"integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", "integrity": "sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.1.1", "@vitest/utils": "3.1.2",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
"funding": { "funding": {
@ -2762,13 +2762,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.2.tgz",
"integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", "integrity": "sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.1.1", "@vitest/pretty-format": "3.1.2",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@ -2777,9 +2777,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.2.tgz",
"integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", "integrity": "sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2790,13 +2790,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.2.tgz",
"integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", "integrity": "sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.1.1", "@vitest/pretty-format": "3.1.2",
"loupe": "^3.1.3", "loupe": "^3.1.3",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@ -4006,9 +4006,9 @@
} }
}, },
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.6.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -5965,9 +5965,9 @@
} }
}, },
"node_modules/maplibre-gl": { "node_modules/maplibre-gl": {
"version": "5.3.1", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.3.1.tgz", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.4.0.tgz",
"integrity": "sha512-Ihx+oUUSsZkjMou1Cw5J6silE+5OtFFQSPslWF9+7v4yFC/XDHrpsORYO9lWE4KZI0djCEUpZQJpkpnMArAbeA==", "integrity": "sha512-ZVrtdFIhFAqt53H2k5Ssqn7QIKNI19fW+He5tr4loxZxWZffp1aZYY9ImNncAJaALU/NYlV6Eul7UVB56/N7WQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@mapbox/geojson-rewind": "^0.5.2", "@mapbox/geojson-rewind": "^0.5.2",
@ -8178,9 +8178,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.27.3", "version": "5.28.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.27.3.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.28.2.tgz",
"integrity": "sha512-MK16NUEFwAunCkdJpIIJ6hvKElx0zFlKMqQd7NAIugMfrL0YeOH8VEn5pg9g2Q6RLj2JrGJL6c0zaAwmXx/nHQ==", "integrity": "sha512-FbWBxgWOpQfhKvoGJv/TFwzqb4EhJbwCD17dB0tEpQiw1XyUEKZJtgm4nA4xq3LLsMo7hu5UY/BOFmroAxKTMg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
@ -8256,9 +8256,9 @@
} }
}, },
"node_modules/svelte-gestures": { "node_modules/svelte-gestures": {
"version": "5.1.3", "version": "5.1.4",
"resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.1.3.tgz", "resolved": "https://registry.npmjs.org/svelte-gestures/-/svelte-gestures-5.1.4.tgz",
"integrity": "sha512-ELOlzuH9E4+S1biCCTfusRlvzFpnqRPlljEqayoBTu5STH42u0kTT45D1m3Py3E9UmIyZTgrSLw6Fus/fh75Dw==", "integrity": "sha512-gfSO/GqWLu9nRMCz12jqdyA0+NTsojYcIBcRqZjwWrpQbqMXr0zWPFpZBtzfYbRHtuFxZImMZp9MrVaFCYbhDg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/svelte-i18n": { "node_modules/svelte-i18n": {
@ -8922,15 +8922,15 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.30.1", "version": "8.31.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.30.1.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.31.0.tgz",
"integrity": "sha512-D7lC0kcehVH7Mb26MRQi64LMyRJsj3dToJxM1+JVTl53DQSV5/7oUGWQLcKl1C1KnoVHxMMU2FNQMffr7F3Row==", "integrity": "sha512-u+93F0sB0An8WEAPtwxVhFby573E8ckdjwUUQUj9QA4v8JAvgtoDdIyYR3XFwFHq2W1KJ1AurwJCO+w+Y1ixyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.30.1", "@typescript-eslint/eslint-plugin": "8.31.0",
"@typescript-eslint/parser": "8.30.1", "@typescript-eslint/parser": "8.31.0",
"@typescript-eslint/utils": "8.30.1" "@typescript-eslint/utils": "8.31.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -9057,18 +9057,18 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.2", "version": "6.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz",
"integrity": "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==", "integrity": "sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.3", "fdir": "^6.4.4",
"picomatch": "^4.0.2", "picomatch": "^4.0.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"rollup": "^4.34.9", "rollup": "^4.34.9",
"tinyglobby": "^0.2.12" "tinyglobby": "^0.2.13"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"
@ -9147,9 +9147,9 @@
} }
}, },
"node_modules/vite-node": { "node_modules/vite-node": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.2.tgz",
"integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", "integrity": "sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -9621,31 +9621,32 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.1.1", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.2.tgz",
"integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", "integrity": "sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/expect": "3.1.1", "@vitest/expect": "3.1.2",
"@vitest/mocker": "3.1.1", "@vitest/mocker": "3.1.2",
"@vitest/pretty-format": "^3.1.1", "@vitest/pretty-format": "^3.1.2",
"@vitest/runner": "3.1.1", "@vitest/runner": "3.1.2",
"@vitest/snapshot": "3.1.1", "@vitest/snapshot": "3.1.2",
"@vitest/spy": "3.1.1", "@vitest/spy": "3.1.2",
"@vitest/utils": "3.1.1", "@vitest/utils": "3.1.2",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.0", "debug": "^4.4.0",
"expect-type": "^1.2.0", "expect-type": "^1.2.1",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"std-env": "^3.8.1", "std-env": "^3.9.0",
"tinybench": "^2.9.0", "tinybench": "^2.9.0",
"tinyexec": "^0.3.2", "tinyexec": "^0.3.2",
"tinyglobby": "^0.2.13",
"tinypool": "^1.0.2", "tinypool": "^1.0.2",
"tinyrainbow": "^2.0.0", "tinyrainbow": "^2.0.0",
"vite": "^5.0.0 || ^6.0.0", "vite": "^5.0.0 || ^6.0.0",
"vite-node": "3.1.1", "vite-node": "3.1.2",
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
}, },
"bin": { "bin": {
@ -9661,8 +9662,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.1.1", "@vitest/browser": "3.1.2",
"@vitest/ui": "3.1.1", "@vitest/ui": "3.1.2",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },

View File

@ -12,7 +12,7 @@
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore' --ignore src/lib/components/photos-page/asset-grid.svelte",
"check:typescript": "tsc --noEmit", "check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch", "check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript", "check:code": "npm run format && npm run lint:p && npm run check:svelte && npm run check:typescript",
"check:all": "npm run check:code && npm run test:cov", "check:all": "npm run check:code && npm run test:cov",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"lint:p": "eslint-p . --max-warnings 0 --concurrency=4", "lint:p": "eslint-p . --max-warnings 0 --concurrency=4",

View File

@ -1,16 +1,17 @@
<script lang="ts"> <script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte'; import ConfirmDialog from '$lib/components/shared-components/dialog/confirm-dialog.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { type SystemConfigDto } from '@immich/sdk'; import { SettingInputFieldType } from '$lib/constants';
import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings'; import type { SettingsResetEvent, SettingsSaveEvent } from '../admin-settings';
import { t } from 'svelte-i18n';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { SettingInputFieldType } from '$lib/constants';
interface Props { interface Props {
savedConfig: SystemConfigDto; savedConfig: SystemConfigDto;
@ -108,7 +109,7 @@
<hr /> <hr />
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_issuer_url').toUpperCase()} label="ISSUER_URL"
bind:value={config.oauth.issuerUrl} bind:value={config.oauth.issuerUrl}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -117,7 +118,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_client_id').toUpperCase()} label="CLIENT_ID"
bind:value={config.oauth.clientId} bind:value={config.oauth.clientId}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -126,16 +127,30 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_client_secret').toUpperCase()} label="CLIENT_SECRET"
description={$t('admin.oauth_client_secret_description')}
bind:value={config.oauth.clientSecret} bind:value={config.oauth.clientSecret}
required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)} isEdited={!(config.oauth.clientSecret == savedConfig.oauth.clientSecret)}
/> />
{#if config.oauth.clientSecret}
<SettingSelect
label="TOKEN_ENDPOINT_AUTH_METHOD"
bind:value={config.oauth.tokenEndpointAuthMethod}
disabled={disabled || !config.oauth.enabled || !config.oauth.clientSecret}
isEdited={!(config.oauth.tokenEndpointAuthMethod == savedConfig.oauth.tokenEndpointAuthMethod)}
options={[
{ value: OAuthTokenEndpointAuthMethod.ClientSecretPost, text: 'client_secret_post' },
{ value: OAuthTokenEndpointAuthMethod.ClientSecretBasic, text: 'client_secret_basic' },
]}
name="tokenEndpointAuthMethod"
/>
{/if}
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_scope').toUpperCase()} label="SCOPE"
bind:value={config.oauth.scope} bind:value={config.oauth.scope}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -144,7 +159,7 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_signing_algorithm').toUpperCase()} label="ID_TOKEN_SIGNED_RESPONSE_ALG"
bind:value={config.oauth.signingAlgorithm} bind:value={config.oauth.signingAlgorithm}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
@ -153,14 +168,23 @@
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_profile_signing_algorithm').toUpperCase()} label="USERINFO_SIGNED_RESPONSE_ALG"
description={$t('admin.oauth_profile_signing_algorithm_description')}
bind:value={config.oauth.profileSigningAlgorithm} bind:value={config.oauth.profileSigningAlgorithm}
required={true} required={true}
disabled={disabled || !config.oauth.enabled} disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.profileSigningAlgorithm == savedConfig.oauth.profileSigningAlgorithm)} isEdited={!(config.oauth.profileSigningAlgorithm == savedConfig.oauth.profileSigningAlgorithm)}
/> />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_timeout').toUpperCase()}
description={$t('admin.oauth_timeout_description')}
required={true}
bind:value={config.oauth.timeout}
disabled={disabled || !config.oauth.enabled}
isEdited={!(config.oauth.timeout == savedConfig.oauth.timeout)}
/>
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_storage_label_claim').toUpperCase()} label={$t('admin.oauth_storage_label_claim').toUpperCase()}

View File

@ -49,7 +49,7 @@
{@render header?.()} {@render header?.()}
</header> </header>
<main <div
tabindex="-1" tabindex="-1"
class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]" class="relative grid h-dvh grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg sidebar:grid-cols-[theme(spacing.64)_auto]"
> >
@ -59,7 +59,7 @@
<SideBar /> <SideBar />
{/if} {/if}
<section class="relative"> <main class="relative">
{#if title || buttons} {#if title || buttons}
<div <div
class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 dark:border-immich-dark-gray dark:text-immich-dark-fg" class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 dark:border-immich-dark-gray dark:text-immich-dark-fg"
@ -79,5 +79,5 @@
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}> <div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto" use:useActions={use}>
{@render children?.()} {@render children?.()}
</div> </div>
</section>
</main> </main>
</div>

View File

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js'; import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
import Button from '$lib/components/elements/buttons/button.svelte'; import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import OnboardingCard from './onboarding-card.svelte';
import { colorTheme } from '$lib/stores/preferences.store';
import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
import { Theme } from '$lib/constants'; import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props { interface Props {
onDone: () => void; onDone: () => void;
@ -24,7 +24,7 @@
<button <button
type="button" type="button"
class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent" class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
onclick={() => ($colorTheme.value = Theme.LIGHT)} onclick={() => themeManager.setTheme(Theme.LIGHT)}
> >
<div <div
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary" class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
@ -36,7 +36,7 @@
<button <button
type="button" type="button"
class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent" class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
onclick={() => ($colorTheme.value = Theme.DARK)} onclick={() => themeManager.setTheme(Theme.DARK)}
> >
<div <div
class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary" class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"

View File

@ -128,7 +128,7 @@
> >
<!-- Date group title --> <!-- Date group title -->
<div <div
class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" class="flex z-[100] pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
style:width={dateGroup.width + 'px'} style:width={dateGroup.width + 'px'}
> >
{#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))} {#if !singleSelect && ((hoveredDateGroup === dateGroup.groupTitle && isMouseOverGroup) || assetInteraction.selectedGroup.has(dateGroup.groupTitle))}

View File

@ -89,7 +89,16 @@
const usingMobileDevice = $derived(mobileDevice.pointerCoarse); const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
$effect(() => { $effect(() => {
assetStore.rowHeight = maxMd ? 100 : 235; const layoutOptions = maxMd
? {
rowHeight: 100,
headerHeight: 32,
}
: {
rowHeight: 235,
headerHeight: 48,
};
assetStore.setLayoutOptions(layoutOptions);
}); });
const scrollTo = (top: number) => { const scrollTo = (top: number) => {

View File

@ -9,7 +9,7 @@
<div class="overflow-clip" style:height={height + 'px'}> <div class="overflow-clip" style:height={height + 'px'}>
<div <div
class="flex z-[100] pt-[calc(1.75rem+1px)] pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm" class="flex z-[100] pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-immich-bg dark:bg-immich-dark-bg dark:text-immich-dark-fg md:text-sm"
> >
{title} {title}
</div> </div>

View File

@ -67,7 +67,7 @@
</script> </script>
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent"> <div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
<div <nav
id="asset-selection-app-bar" id="asset-selection-app-bar"
class={[ class={[
'grid', 'grid',
@ -94,5 +94,5 @@
<div class="me-4 flex place-items-center gap-1 justify-self-end"> <div class="me-4 flex place-items-center gap-1 justify-self-end">
{@render trailing?.()} {@render trailing?.()}
</div> </div>
</div> </nav>
</div> </div>

View File

@ -9,15 +9,15 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants'; import { Theme } from '$lib/constants';
import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; import { themeManager } from '$lib/managers/theme-manager.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store'; import { serverConfig } from '$lib/stores/server-config.store';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { type MapMarkerResponseDto } from '@immich/sdk'; import { type MapMarkerResponseDto } from '@immich/sdk';
import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url'; import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
import { type GeoJSONSource, GlobeControl, type LngLatLike } from 'maplibre-gl'; import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl';
import maplibregl from 'maplibre-gl';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { import {
AttributionControl, AttributionControl,
@ -68,7 +68,7 @@
let map: maplibregl.Map | undefined = $state(); let map: maplibregl.Map | undefined = $state();
let marker: maplibregl.Marker | null = null; let marker: maplibregl.Marker | null = null;
const theme = $derived($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT); const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT);
const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl); const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl);
export function addClipMapMarker(lng: number, lat: number) { export function addClipMapMarker(lng: number, lat: number) {

View File

@ -55,7 +55,7 @@
<HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} {info} /> <HelpAndFeedbackModal onClose={() => (shouldShowHelpPanel = false)} {info} />
{/if} {/if}
<section <nav
id="dashboard-navbar" id="dashboard-navbar"
class="fixed z-[900] max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm" class="fixed z-[900] max-md:h-[var(--navbar-height-md)] h-[var(--navbar-height)] w-dvw text-sm"
> >
@ -209,4 +209,4 @@
</section> </section>
</div> </div>
</div> </div>
</section> </nav>

View File

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import QRCode from 'qrcode';
import { colorTheme } from '$lib/stores/preferences.store';
import { Theme } from '$lib/constants'; import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import QRCode from 'qrcode';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
type Props = { type Props = {
@ -14,7 +14,7 @@
let promise = $derived( let promise = $derived(
QRCode.toDataURL(value, { QRCode.toDataURL(value, {
color: { dark: $colorTheme.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' }, color: { dark: themeManager.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' },
margin: 0, margin: 0,
width, width,
}), }),

View File

@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { SettingInputFieldType } from '$lib/constants';
import { onMount, tick, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import type { FormEventHandler } from 'svelte/elements'; import type { FormEventHandler } from 'svelte/elements';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import PasswordField from '../password-field.svelte'; import PasswordField from '../password-field.svelte';
import { t } from 'svelte-i18n';
import { onMount, tick, type Snippet } from 'svelte';
import { SettingInputFieldType } from '$lib/constants';
interface Props { interface Props {
inputType: SettingInputFieldType; inputType: SettingInputFieldType;
value: string | number; value: string | number | undefined;
min?: number; min?: number;
max?: number; max?: number;
step?: string; step?: string;
@ -147,7 +147,7 @@
name={label} name={label}
autocomplete={passwordAutocomplete} autocomplete={passwordAutocomplete}
{required} {required}
password={value.toString()} password={(value || '').toString()}
onInput={(passwordValue) => (value = passwordValue)} onInput={(passwordValue) => (value = passwordValue)}
{disabled} {disabled}
{title} {title}

View File

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { mdiChevronDown } from '@mdi/js'; import { mdiChevronDown } from '@mdi/js';
import { t } from 'svelte-i18n';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
interface Props { interface Props {
value: string | number; value: string | number | undefined;
options: { value: string | number; text: string }[]; options: { value: string | number; text: string }[];
label?: string; label?: string;
desc?: string; desc?: string;

View File

@ -7,14 +7,12 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
</script> </script>
<SideBarSection> <SideBarSection ariaLabel={$t('primary')}>
<nav aria-label={$t('primary')}>
<SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} /> <SideBarLink title={$t('users')} routeId={AppRoute.ADMIN_USER_MANAGEMENT} icon={mdiAccountMultipleOutline} />
<SideBarLink title={$t('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} /> <SideBarLink title={$t('jobs')} routeId={AppRoute.ADMIN_JOBS} icon={mdiSync} />
<SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} /> <SideBarLink title={$t('settings')} routeId={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
<SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} /> <SideBarLink title={$t('external_libraries')} routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
<SideBarLink title={$t('server_stats')} routeId={AppRoute.ADMIN_STATS} icon={mdiServer} /> <SideBarLink title={$t('server_stats')} routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
</nav>
<BottomInfo /> <BottomInfo />
</SideBarSection> </SideBarSection>

View File

@ -7,10 +7,11 @@
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
interface Props { interface Props {
ariaLabel?: string;
children?: Snippet; children?: Snippet;
} }
let { children }: Props = $props(); let { ariaLabel, children }: Props = $props();
const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar); const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar); const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar);
@ -30,8 +31,9 @@
}; };
</script> </script>
<section <nav
id="sidebar" id="sidebar"
aria-label={ariaLabel}
tabindex="-1" tabindex="-1"
class="immich-scrollbar relative z-10 w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg" class="immich-scrollbar relative z-10 w-0 sidebar:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
class:shadow-2xl={isExpanded} class:shadow-2xl={isExpanded}
@ -46,4 +48,4 @@
<div class="pe-6 flex flex-col gap-1 h-max min-h-full"> <div class="pe-6 flex flex-col gap-1 h-max min-h-full">
{@render children?.()} {@render children?.()}
</div> </div>
</section> </nav>

View File

@ -42,8 +42,7 @@
let isUtilitiesSelected: boolean = $state(false); let isUtilitiesSelected: boolean = $state(false);
</script> </script>
<SideBarSection> <SideBarSection ariaLabel={$t('primary')}>
<nav aria-label={$t('primary')}>
<SideBarLink <SideBarLink
title={$t('photos')} title={$t('photos')}
routeId="/(user)/photos" routeId="/(user)/photos"
@ -137,7 +136,6 @@
icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline} icon={isTrashSelected ? mdiTrashCan : mdiTrashCanOutline}
></SideBarLink> ></SideBarLink>
{/if} {/if}
</nav>
<BottomInfo /> <BottomInfo />
</SideBarSection> </SideBarSection>

View File

@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths'; import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
import CircleIconButton, { type Padding } from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton, { type Padding } from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { Theme } from '$lib/constants'; import { themeManager } from '$lib/managers/theme-manager.svelte';
import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
let icon = $derived($colorTheme.value === Theme.LIGHT ? moonPath : sunPath); let icon = $derived(themeManager.isDark ? sunPath : moonPath);
let viewBox = $derived($colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox); let viewBox = $derived(themeManager.isDark ? sunViewBox : moonViewBox);
let isDark = $derived($colorTheme.value === Theme.DARK);
interface Props { interface Props {
padding?: Padding; padding?: Padding;
@ -16,14 +14,14 @@
let { padding = '3' }: Props = $props(); let { padding = '3' }: Props = $props();
</script> </script>
{#if !$colorTheme.system} {#if !themeManager.theme.system}
<CircleIconButton <CircleIconButton
title={$t('toggle_theme')} title={$t('toggle_theme')}
{icon} {icon}
{viewBox} {viewBox}
role="switch" role="switch"
aria-checked={isDark ? 'true' : 'false'} aria-checked={themeManager.isDark ? 'true' : 'false'}
onclick={handleToggleTheme} onclick={() => themeManager.toggleTheme()}
{padding} {padding}
/> />
{/if} {/if}

View File

@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte'; import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { defaultLang, fallbackLocale, langs, locales } from '$lib/constants'; import { defaultLang, fallbackLocale, langs, locales } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { import {
alwaysLoadOriginalFile, alwaysLoadOriginalFile,
colorTheme,
lang, lang,
locale, locale,
loopVideo, loopVideo,
@ -17,7 +18,6 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { locale as i18nLocale, t } from 'svelte-i18n'; import { locale as i18nLocale, t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { invalidateAll } from '$app/navigation';
let time = $state(new Date()); let time = $state(new Date());
@ -40,10 +40,6 @@
})); }));
}; };
const handleToggleColorTheme = () => {
$colorTheme.system = !$colorTheme.system;
};
const handleToggleLocaleBrowser = () => { const handleToggleLocaleBrowser = () => {
$locale = $locale ? undefined : fallbackLocale.code; $locale = $locale ? undefined : fallbackLocale.code;
}; };
@ -101,8 +97,8 @@
<SettingSwitch <SettingSwitch
title={$t('theme_selection')} title={$t('theme_selection')}
subtitle={$t('theme_selection_description')} subtitle={$t('theme_selection_description')}
bind:checked={$colorTheme.system} checked={themeManager.theme.system}
onToggle={handleToggleColorTheme} onToggle={(isChecked) => themeManager.setSystem(isChecked)}
/> />
</div> </div>

View File

@ -1,3 +1,4 @@
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type { LoginResponseDto } from '@immich/sdk'; import type { LoginResponseDto } from '@immich/sdk';
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void; type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;
@ -56,4 +57,5 @@ export const eventManager = new EventManager<{
'auth.login': [LoginResponseDto]; 'auth.login': [LoginResponseDto];
'auth.logout': []; 'auth.logout': [];
'language.change': [{ name: string; code: string; rtl?: boolean }]; 'language.change': [{ name: string; code: string; rtl?: boolean }];
'theme.change': [ThemeSetting];
}>(); }>();

View File

@ -0,0 +1,78 @@
import { browser } from '$app/environment';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
export interface ThemeSetting {
value: Theme;
system: boolean;
}
const getDefaultTheme = () => {
if (!browser) {
return Theme.DARK;
}
return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
};
class ThemeManager {
#theme = new PersistedLocalStorage<ThemeSetting>(
'color-theme',
{ value: getDefaultTheme(), system: false },
{
valid: (value): value is ThemeSetting => {
return Object.values(Theme).includes((value as ThemeSetting)?.value);
},
},
);
get theme() {
return this.#theme.current;
}
value = $derived(this.theme.value);
isDark = $derived(this.value === Theme.DARK);
constructor() {
eventManager.on('app.init', () => this.#onAppInit());
}
setSystem(system: boolean) {
this.#update(system ? 'system' : getDefaultTheme());
}
setTheme(theme: Theme) {
this.#update(theme);
}
toggleTheme() {
this.#update(this.value === Theme.DARK ? Theme.LIGHT : Theme.DARK);
}
#onAppInit() {
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.theme.system) {
this.#update('system');
}
});
}
#update(value: Theme | 'system') {
const theme: ThemeSetting =
value === 'system' ? { system: true, value: getDefaultTheme() } : { system: false, value };
if (theme.value === Theme.LIGHT) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
this.#theme.current = theme;
eventManager.emit('theme.change', theme);
}
}
export const themeManager = new ThemeManager();

View File

@ -48,15 +48,15 @@ describe('AssetStore', () => {
expect(plainBuckets).toEqual( expect(plainBuckets).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 304 }), expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }),
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4515.333_333_333_333 }), expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }),
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }), expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
]), ]),
); );
}); });
it('calculates timeline height', () => { it('calculates timeline height', () => {
expect(assetStore.timelineHeight).toBe(5105.333_333_333_333); expect(assetStore.timelineHeight).toBe(5103.333_333_333_333);
}); });
}); });

View File

@ -35,9 +35,7 @@ export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
timelineAlbumId?: string; timelineAlbumId?: string;
deferInit?: boolean; deferInit?: boolean;
}; };
export type AssetStoreLayoutOptions = {
rowHeight: number;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
function updateObject(target: any, source: any): boolean { function updateObject(target: any, source: any): boolean {
if (!target) { if (!target) {
@ -110,7 +108,6 @@ export class AssetDateGroup {
readonly date: DateTime; readonly date: DateTime;
readonly dayOfMonth: number; readonly dayOfMonth: number;
intersetingAssets: IntersectingAsset[] = $state([]); intersetingAssets: IntersectingAsset[] = $state([]);
dodo: IntersectingAsset[] = $state([]);
height = $state(0); height = $state(0);
width = $state(0); width = $state(0);
@ -121,6 +118,7 @@ export class AssetDateGroup {
left: number = $state(0); left: number = $state(0);
row = $state(0); row = $state(0);
col = $state(0); col = $state(0);
deferredLayout = false;
constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) { constructor(bucket: AssetBucket, index: number, date: DateTime, dayOfMonth: number) {
this.index = index; this.index = index;
@ -195,6 +193,10 @@ export class AssetDateGroup {
} }
layout(options: CommonLayoutOptions) { layout(options: CommonLayoutOptions) {
if (!this.bucket.intersecting) {
this.deferredLayout = true;
return;
}
const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!); const assets = this.intersetingAssets.map((intersetingAsset) => intersetingAsset.asset!);
const geometry = getJustifiedLayoutFromAssets(assets, options); const geometry = getJustifiedLayoutFromAssets(assets, options);
this.width = geometry.containerWidth; this.width = geometry.containerWidth;
@ -547,6 +549,11 @@ export type LiteBucket = {
bucketDateFormattted: string; bucketDateFormattted: string;
}; };
type AssetStoreLayoutOptions = {
rowHeight?: number;
headerHeight?: number;
gap?: number;
};
export class AssetStore { export class AssetStore {
// --- public ---- // --- public ----
isInitialized = $state(false); isInitialized = $state(false);
@ -596,7 +603,7 @@ export class AssetStore {
#unsubscribers: Unsubscriber[] = []; #unsubscribers: Unsubscriber[] = [];
#rowHeight = $state(235); #rowHeight = $state(235);
#headerHeight = $state(49); #headerHeight = $state(48);
#gap = $state(12); #gap = $state(12);
#options: AssetStoreOptions = AssetStore.#INIT_OPTIONS; #options: AssetStoreOptions = AssetStore.#INIT_OPTIONS;
@ -608,36 +615,46 @@ export class AssetStore {
constructor() {} constructor() {}
set headerHeight(value) { setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: AssetStoreLayoutOptions) {
let changed = false;
changed ||= this.#setHeaderHeight(headerHeight);
changed ||= this.#setGap(gap);
changed ||= this.#setRowHeight(rowHeight);
if (changed) {
this.refreshLayout();
}
}
#setHeaderHeight(value: number) {
if (this.#headerHeight == value) { if (this.#headerHeight == value) {
return; return false;
} }
this.#headerHeight = value; this.#headerHeight = value;
this.refreshLayout(); return true;
} }
get headerHeight() { get headerHeight() {
return this.#headerHeight; return this.#headerHeight;
} }
set gap(value) { #setGap(value: number) {
if (this.#gap == value) { if (this.#gap == value) {
return; return false;
} }
this.#gap = value; this.#gap = value;
this.refreshLayout(); return true;
} }
get gap() { get gap() {
return this.#gap; return this.#gap;
} }
set rowHeight(value) { #setRowHeight(value: number) {
if (this.#rowHeight == value) { if (this.#rowHeight == value) {
return; return false;
} }
this.#rowHeight = value; this.#rowHeight = value;
this.refreshLayout(); return true;
} }
get rowHeight() { get rowHeight() {
@ -815,6 +832,15 @@ export class AssetStore {
} }
bucket.intersecting = actuallyIntersecting || preIntersecting; bucket.intersecting = actuallyIntersecting || preIntersecting;
bucket.actuallyIntersecting = actuallyIntersecting; bucket.actuallyIntersecting = actuallyIntersecting;
if (preIntersecting || actuallyIntersecting) {
const hasDeferred = bucket.dateGroups.some((group) => group.deferredLayout);
if (hasDeferred) {
this.#updateGeometry(bucket, true);
for (const group of bucket.dateGroups) {
group.deferredLayout = false;
}
}
}
} }
#processPendingChanges = throttle(() => { #processPendingChanges = throttle(() => {

View File

@ -2,39 +2,12 @@ import { browser } from '$app/environment';
import { Theme, defaultLang } from '$lib/constants'; import { Theme, defaultLang } from '$lib/constants';
import { getPreferredLocale } from '$lib/utils/i18n'; import { getPreferredLocale } from '$lib/utils/i18n';
import { persisted } from 'svelte-persisted-store'; import { persisted } from 'svelte-persisted-store';
import { get } from 'svelte/store';
export interface ThemeSetting { export interface ThemeSetting {
value: Theme; value: Theme;
system: boolean; system: boolean;
} }
export const handleToggleTheme = () => {
const theme = get(colorTheme);
theme.value = theme.value === Theme.DARK ? Theme.LIGHT : Theme.DARK;
colorTheme.set(theme);
};
const initTheme = (): ThemeSetting => {
if (browser && globalThis.matchMedia && !globalThis.matchMedia('(prefers-color-scheme: dark)').matches) {
return { value: Theme.LIGHT, system: false };
}
return { value: Theme.DARK, system: false };
};
const initialTheme = initTheme();
// The 'color-theme' key is also used by app.html to prevent FOUC on page load.
export const colorTheme = persisted<ThemeSetting>('color-theme', initialTheme, {
serializer: {
parse: (text: string): ThemeSetting => {
const parsedText: ThemeSetting = JSON.parse(text);
return Object.values(Theme).includes(parsedText.value) ? parsedText : initTheme();
},
stringify: (object) => JSON.stringify(object),
},
});
// Locale to use for formatting dates, numbers, etc. // Locale to use for formatting dates, numbers, etc.
export const locale = persisted<string | undefined>('locale', undefined, { export const locale = persisted<string | undefined>('locale', undefined, {
serializer: { serializer: {

View File

@ -0,0 +1,81 @@
import { browser } from '$app/environment';
import { createSubscriber } from 'svelte/reactivity';
type PersistedBaseOptions<T> = {
read: (key: string) => T | undefined;
write: (key: string, value: T) => void;
};
class PersistedBase<T> {
#value: T;
#subscribe: () => void;
#update = () => {};
#write: (value: T) => void;
get current() {
this.#subscribe();
return this.#value as T;
}
set current(value: T) {
this.#write(value);
this.#update();
this.#value = value;
}
constructor(key: string, defaultValue: T, options: PersistedBaseOptions<T>) {
const value = options.read(key);
this.#value = value === undefined ? defaultValue : value;
this.#write = (value: T) => options.write(key, value);
this.#subscribe = createSubscriber((update) => {
this.#update = update;
return () => {
this.#update = () => {};
};
});
}
}
type PersistedLocalStorageOptions<T> = {
serializer?: {
stringify(value: T): string;
parse(text: string): T;
};
valid?: (value: T | unknown) => value is T;
};
export class PersistedLocalStorage<T> extends PersistedBase<T> {
constructor(key: string, defaultValue: T, options: PersistedLocalStorageOptions<T> = {}) {
const valid = options.valid || (() => true);
const serializer = options.serializer || JSON;
super(key, defaultValue, {
read: (key: string) => {
if (!browser) {
return;
}
const item = localStorage.getItem(key) ?? undefined;
if (item === undefined) {
return;
}
const parsed = serializer.parse(item);
if (!valid(parsed)) {
return;
}
return parsed;
},
write: (key: string, value: T) => {
if (browser) {
localStorage.setItem(key, serializer.stringify(value));
}
},
});
}
}

View File

@ -10,16 +10,14 @@
import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte'; import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte'; import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte'; import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { colorTheme, handleToggleTheme, type ThemeSetting } from '$lib/stores/preferences.store';
import { serverConfig } from '$lib/stores/server-config.store'; import { serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket'; import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { isAssetViewerRoute } from '$lib/utils/navigation'; import { isAssetViewerRoute } from '$lib/utils/navigation';
import { setTranslations } from '@immich/ui'; import { setTranslations } from '@immich/ui';
import { onDestroy, onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy'; import { run } from 'svelte/legacy';
import '../app.css'; import '../app.css';
@ -40,24 +38,6 @@
let showNavigationLoadingBar = $state(false); let showNavigationLoadingBar = $state(false);
const changeTheme = (theme: ThemeSetting) => {
if (theme.system) {
theme.value = globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
}
if (theme.value === Theme.LIGHT) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
};
const handleChangeTheme = () => {
if ($colorTheme.system) {
handleToggleTheme();
}
};
const getMyImmichLink = () => { const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app'); return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
}; };
@ -66,11 +46,6 @@
const element = document.querySelector('#stencil'); const element = document.querySelector('#stencil');
element?.remove(); element?.remove();
// if the browser theme changes, changes the Immich theme too // if the browser theme changes, changes the Immich theme too
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme);
});
onDestroy(() => {
document.removeEventListener('change', handleChangeTheme);
}); });
eventManager.emit('app.init'); eventManager.emit('app.init');
@ -85,9 +60,6 @@
afterNavigate(() => { afterNavigate(() => {
showNavigationLoadingBar = false; showNavigationLoadingBar = false;
}); });
run(() => {
changeTheme($colorTheme);
});
run(() => { run(() => {
if ($user) { if ($user) {
openWebsocketConnection(); openWebsocketConnection();

View File

@ -4,3 +4,15 @@ import { init } from 'svelte-i18n';
beforeAll(async () => { beforeAll(async () => {
await init({ fallbackLocale: 'dev' }); await init({ fallbackLocale: 'dev' });
}); });
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});

View File

@ -0,0 +1,7 @@
# This site is running an Immich instance.
# Immich-related security problems should be reported to the Immich security team.
# Security problems related to this instance should be reported to its administration.
Policy: https://github.com/immich-app/immich/blob/main/SECURITY.md
Contact: mailto:security@immich.app
Preferred-Languages: en
Expires: 2026-05-01T23:59:00.000Z