Compare commits

..

1 Commits

Author SHA1 Message Date
mertalev
e62071fda0 don't use iterator.find 2025-06-19 17:40:56 +03:00
1162 changed files with 20826 additions and 52362 deletions

View File

@@ -73,8 +73,10 @@ install_dependencies() {
log "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1
run_cmd make setup-web-dev setup-server-dev
run_cmd make install-server
run_cmd make install-sdk
run_cmd make build-sdk
run_cmd make install-web
)
log ""
}

View File

@@ -3,7 +3,6 @@ services:
build:
target: dev-container-server
env_file: !reset []
hostname: immich-dev
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override
@@ -22,7 +21,7 @@ services:
immich-machine-learning:
env_file: !reset []
database:
env_file: !reset []
environment: !override
@@ -31,7 +30,7 @@ services:
POSTGRES_DB: ${DB_DATABASE_NAME-immich}
POSTGRES_INITDB_ARGS: '--data-checksums'
POSTGRES_HOST_AUTH_METHOD: md5
volumes:
volumes:
- ${UPLOAD_LOCATION:-postgres-devcontainer-volume}${UPLOAD_LOCATION:+/postgres}:/var/lib/postgresql/data
redis:

View File

@@ -4,14 +4,9 @@
design/
docker/
Dockerfile
!docker/scripts
docs/
!docs/package.json
!docs/package-lock.json
e2e/
!e2e/package.json
!e2e/package-lock.json
fastlane/
machine-learning/
misc/
@@ -20,7 +15,6 @@ mobile/
cli/coverage/
cli/dist/
cli/node_modules/
cli/Dockerfile
open-api/typescript-sdk/build/
open-api/typescript-sdk/node_modules/
@@ -31,11 +25,9 @@ server/upload/
server/src/queries
server/dist/
server/www/
server/Dockerfile
web/node_modules/
web/coverage/
web/.svelte-kit
web/build/
web/.env
web/Dockerfile

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
22.17.0
22.16.0

View File

@@ -1,4 +0,0 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

6
.github/package-lock.json generated vendored
View File

@@ -9,9 +9,9 @@
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -58,7 +58,7 @@ jobs:
contents: read
# Skip when PR from a fork
if: ${{ !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]' && needs.pre-job.outputs.should_run == 'true' }}
runs-on: mich
runs-on: macos-14
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -66,40 +66,24 @@ jobs:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Create the Keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: build-mobile-gradle-${{ runner.os }}-main
cache: 'gradle'
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
cache: true
- name: Setup Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3.2.2
with:
packages: ''
- name: Create the Keystore
env:
KEY_JKS: ${{ secrets.KEY_JKS }}
working-directory: ./mobile
run: echo $KEY_JKS | base64 -d > android/key.jks
- name: Get Packages
working-directory: ./mobile
@@ -119,30 +103,12 @@ jobs:
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
if [[ $IS_MAIN == 'true' ]]; then
flutter build apk --release --flavor production
flutter build apk --release --flavor production --split-per-abi --target-platform android-arm,android-arm64,android-x64
else
flutter build apk --debug --flavor production --split-per-abi --target-platform android-arm64
fi
flutter build apk --release
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
- name: Publish Android Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-apk-signed
path: mobile/build/app/outputs/flutter-apk/**/*.apk
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4
if: github.ref == 'refs/heads/main'
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/sdk
mobile/android/.gradle
mobile/.dart_tool
key: ${{ steps.cache-gradle-restore.outputs.cache-primary-key }}
path: mobile/build/app/outputs/flutter-apk/*.apk

View File

@@ -38,9 +38,6 @@ jobs:
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Prepare SDK
run: npm ci --prefix ../open-api/typescript-sdk/
- name: Build SDK
@@ -70,7 +67,7 @@ jobs:
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
@@ -99,7 +96,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
with:
file: cli/Dockerfile
platforms: linux/amd64,linux/arm64

View File

@@ -50,7 +50,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
languages: ${{ matrix.language }}
# 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).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
# 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
@@ -76,6 +76,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
category: '/language:${{matrix.language}}'

View File

@@ -131,7 +131,7 @@ jobs:
tag-suffix: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0
permissions:
contents: read
actions: read
@@ -154,7 +154,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@129aeda75a450666ce96e8bc8126652e717917a7 # multi-runner-build-workflow-0.1.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@094bfb927b8cd75b343abaac27b3241be0fccfe9 # multi-runner-build-workflow-0.1.0
permissions:
contents: read
actions: read
@@ -177,7 +177,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}
@@ -188,6 +188,6 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}

View File

@@ -57,8 +57,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './docs/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci

View File

@@ -32,8 +32,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Fix formatting
run: make install-all && make format-all

View File

@@ -1,13 +0,0 @@
name: Org Checks
on:
pull_request_review:
pull_request:
jobs:
check-approvals:
name: Check for Team/Admin Review
uses: immich-app/devtools/.github/workflows/required-approval.yml@main
permissions:
pull-requests: read
contents: read

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Require PR to have a changelog label
uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5.5.1
uses: mheap/github-action-required-labels@fb29a14a076b0f74099f6198f77750e8fc236016 # v5.5.0
with:
mode: exactly
count: 1

View File

@@ -100,7 +100,7 @@ jobs:
name: release-apk-signed
- name: Create draft release
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -25,8 +25,6 @@ jobs:
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install deps
run: npm ci
- name: Build

View File

@@ -42,9 +42,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: ./mobile
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -52,29 +49,34 @@ jobs:
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
- name: Install dependencies
run: dart pub get
working-directory: ./mobile
- name: Install DCM
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
version: auto
working-directory: ./mobile
run: |
sudo apt-get update
wget -qO- https://dcm.dev/pgp-key.public | sudo gpg --dearmor -o /usr/share/keyrings/dcm.gpg
echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list
sudo apt-get update
sudo apt-get install dcm
- name: Generate translation file
run: make translation
working-directory: ./mobile
- name: Run Build Runner
run: make build
working-directory: ./mobile
- name: Generate platform API
run: make pigeon
working-directory: ./mobile
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4
@@ -96,16 +98,19 @@ jobs:
- name: Run dart analyze
run: dart analyze --fatal-infos
working-directory: ./mobile
- name: Run dart format
run: dart format lib/ --set-exit-if-changed
working-directory: ./mobile
- name: Run dart custom_lint
run: dart run custom_lint
working-directory: ./mobile
# TODO: Use https://github.com/CQLabs/dcm-action
- name: Run DCM
run: dcm analyze lib --fatal-style --fatal-warnings
run: dcm analyze lib
working-directory: ./mobile
zizmor:
name: zizmor
@@ -129,7 +134,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
sarif_file: results.sarif
category: zizmor

View File

@@ -84,8 +84,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
@@ -127,8 +125,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: npm ci && npm run build
@@ -174,8 +170,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './cli/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Setup typescript-sdk
run: npm ci && npm run build
@@ -214,8 +208,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -257,8 +249,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -292,8 +282,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './web/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install dependencies
run: npm --prefix=web ci
@@ -338,8 +326,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -383,8 +369,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
@@ -418,8 +402,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -468,8 +450,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run setup typescript-sdk
run: npm ci && npm run build
@@ -481,7 +461,7 @@ jobs:
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install chromium --only-shell
run: npx playwright install --with-deps chromium
if: ${{ !cancelled() }}
- name: Docker build
@@ -499,7 +479,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}
@@ -516,7 +496,7 @@ jobs:
persist-credentials: false
- name: Setup Flutter SDK
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2.19.0
with:
channel: 'stable'
flutter-version-file: ./mobile/pubspec.yaml
@@ -588,8 +568,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './.github/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Run npm install
run: npm ci
@@ -609,7 +587,7 @@ jobs:
persist-credentials: false
- name: Run ShellCheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
uses: ludeeus/action-shellcheck@master
with:
ignore_paths: >-
**/open-api/**
@@ -631,8 +609,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: npm --prefix=server ci
@@ -668,7 +644,7 @@ jobs:
contents: read
services:
postgres:
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3@sha256:1f5583fe3397210a0fbc7f11b0cec18bacc4a99e3e8ea0548e9bd6bcf26ec37a
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.1
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
@@ -694,8 +670,6 @@ jobs:
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: './server/.nvmrc'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install server dependencies
run: npm ci

View File

@@ -52,6 +52,6 @@ jobs:
permissions: {}
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@6b81b1572e466f7f48ba3c823159ce3f4a4d66a6 # success-check-action-0.0.3
with:
needs: ${{ toJSON(needs) }}

19
.vscode/launch.json vendored
View File

@@ -18,25 +18,6 @@
"name": "Immich Workers",
"remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server"
},
{
"name": "Flavor - Production",
"request": "launch",
"type": "dart",
"codeLens": {
"for": [
"run-test",
"run-test-file",
"run-file",
"debug-test",
"debug-test-file",
"debug-file",
],
"title": "${debugType}",
},
"args": [
"--flavor", "production"
],
}
]
}

View File

@@ -1,33 +1,27 @@
dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans || make dev-down
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
.PHONY: e2e
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
.PHONY: open-api
open-api:
@@ -54,8 +48,6 @@ audit-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) audit fix
install-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) i
ci-%:
npm --prefix $(subst sdk,open-api/typescript-sdk,$*) ci
build-cli: build-sdk
build-web: build-sdk
build-%: install-%
@@ -90,7 +82,6 @@ test-medium-dev:
build-all: $(foreach M,$(filter-out e2e .github,$(MODULES)),build-$M) ;
install-all: $(foreach M,$(MODULES),install-$M) ;
ci-all: $(foreach M,$(filter-out .github,$(MODULES)),ci-$M) ;
check-all: $(foreach M,$(filter-out sdk cli docs .github,$(MODULES)),check-$M) ;
lint-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),lint-$M) ;
format-all: $(foreach M,$(filter-out sdk,$(MODULES)),format-$M) ;
@@ -99,12 +90,9 @@ hygiene-all: lint-all format-all check-all sql audit-all;
test-all: $(foreach M,$(filter-out sdk docs .github,$(MODULES)),test-$M) ;
clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "node_modules" -type d -prune -exec rm -rf '{}' +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name "svelte-kit" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml rm -v -f || true
setup-server-dev: install-server
setup-web-dev: install-sdk build-sdk install-web
docker compose -f ./docker/docker-compose.dev.yml rm -v -f || true
docker compose -f ./e2e/docker-compose.yml rm -v -f || true

View File

@@ -1 +1 @@
22.17.0
22.16.0

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env node
import '../dist/index.js';

530
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
{
"name": "@immich/cli",
"version": "2.2.72",
"version": "2.2.69",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
"bin": {
"immich": "./bin/immich"
"immich": "dist/index.js"
},
"license": "GNU Affero General Public License version 3",
"keywords": [
@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.15.33",
"@types/node": "^22.15.31",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -36,7 +36,7 @@
"prettier-plugin-organize-imports": "^4.0.0",
"typescript": "^5.3.3",
"typescript-eslint": "^8.28.0",
"vite": "^7.0.0",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^5.0.0",
"vitest": "^3.0.0",
"vitest-fetch-mock": "^0.4.0",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "22.17.0"
"node": "22.16.0"
}
}

View File

@@ -116,13 +116,13 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177
healthcheck:
test: redis-cli ping || exit 1
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:5f6a838e4e44c8e0e019d0ebfe3ee8952b69afc2809b2c25f7b0119641978e91
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.2-pgvectors0.2.0
env_file:
- .env
environment:
@@ -134,7 +134,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
shm_size: 128mb
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
# immich-prometheus:
# container_name: immich_prometheus

View File

@@ -56,14 +56,14 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177
healthcheck:
test: redis-cli ping || exit 1
restart: always
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:5f6a838e4e44c8e0e019d0ebfe3ee8952b69afc2809b2c25f7b0119641978e91
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.2-pgvectors0.2.0
env_file:
- .env
environment:
@@ -75,7 +75,6 @@ services:
- ${UPLOAD_LOCATION}/postgres:/var/lib/postgresql/data
ports:
- 5432:5432
shm_size: 128mb
restart: always
# set IMMICH_TELEMETRY_INCLUDE=all in .env to enable metrics
@@ -83,7 +82,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:63805ebb8d2b3920190daf1cb14a60871b16fd38bed42b857a3182bc621f4996
image: prom/prometheus@sha256:9abc6cf6aea7710d163dbb28d8eeb7dc5baef01e38fa4cd146a406dd9f07f70d
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
@@ -95,7 +94,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.0.2-ubuntu@sha256:0512d81cdeaaff0e370a9aa66027b465d1f1f04379c3a9c801a905fabbdbc7a5
image: grafana/grafana:12.0.1-ubuntu@sha256:65575bb9c761335e2ff30e364f21d38632e3b2e75f5f81d83cc92f44b9bbc055
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -49,14 +49,14 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8-bookworm@sha256:facc1d2c3462975c34e10fccb167bfa92b0e0dbd992fc282c29a61c3243afb11
image: docker.io/valkey/valkey:8-bookworm@sha256:fec42f399876eb6faf9e008570597741c87ff7662a54185593e74b09ce83d177
healthcheck:
test: redis-cli ping || exit 1
restart: always
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:5f6a838e4e44c8e0e019d0ebfe3ee8952b69afc2809b2c25f7b0119641978e91
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.2-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
@@ -67,7 +67,6 @@ services:
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
volumes:

View File

@@ -1 +1 @@
22.17.0
22.16.0

View File

@@ -490,7 +490,7 @@ You can also scan the Postgres database file structure for errors:
<details>
<summary>Scan for file structure errors</summary>
```bash
docker exec -it immich_postgres pg_amcheck --username=<DB_USERNAME> --heapallindexed --parent-check --rootdescend --progress --all --install-missing
docker exec -it immich_postgres pg_amcheck --username=postgres --heapallindexed --parent-check --rootdescend --progress --all --install-missing
```
A normal result will end something like this and return with an exit code of `0`:

View File

@@ -57,7 +57,7 @@ Then please follow the steps in the following section for restoring the database
<TabItem value="Linux system" label="Linux system" default>
```bash title='Backup'
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | gzip > "/path/to/backup/dump.sql.gz"
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | gzip > "/path/to/backup/dump.sql.gz"
```
```bash title='Restore'
@@ -79,7 +79,7 @@ docker compose up -d # Start remainder of Immich apps
<TabItem value="Windows system (PowerShell)" label="Windows system (PowerShell)">
```powershell title='Backup'
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME>))
[System.IO.File]::WriteAllLines("C:\absolute\path\to\backup\dump.sql", (docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres))
```
```powershell title='Restore'
@@ -150,10 +150,12 @@ for more info read the [release notes](https://github.com/immich-app/immich/rele
- Preview images (small thumbnails and large previews) for each asset and thumbnails for recognized faces.
- Stored in `UPLOAD_LOCATION/thumbs/<userID>`.
- **Encoded Assets:**
- Videos that have been re-encoded from the original for wider compatibility. The original is not removed.
- Stored in `UPLOAD_LOCATION/encoded-video/<userID>`.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.
@@ -199,6 +201,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO
- Temporarily located in `UPLOAD_LOCATION/upload/<userID>`.
- Transferred to `UPLOAD_LOCATION/library/<userID>` upon successful upload.
- **Postgres**
- The Immich database containing all the information to allow the system to function properly.
**Note:** This folder will only appear to users who have made the changes mentioned in [v1.102.0](https://github.com/immich-app/immich/discussions/8930) (an optional, non-mandatory change) or who started with this version.
- Stored in `DB_DATA_LOCATION`.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -46,12 +46,6 @@ services:
When a new asset is uploaded it kicks off a series of jobs, which include metadata extraction, thumbnail generation, machine learning tasks, and storage template migration, if enabled. To view the status of a job navigate to the Administration -> Jobs page.
Additionally, some jobs run on a schedule, which is every night at midnight. This schedule, with the exception of [External Libraries](/docs/features/libraries) scanning, cannot be changed.
<img src={require('./img/admin-jobs.webp').default} width="60%" title="Admin jobs" />
Additionally, some jobs (such as memories generation) run on a schedule, which is every night at midnight by default. To change when they run or enable/disable a job navigate to System Settings -> [Nightly Tasks Settings](https://my.immich.app/admin/system-settings?isOpen=nightly-tasks).
<img src={require('./img/admin-nightly-tasks.webp').default} width="60%" title="Admin nightly tasks" />
:::note
Some jobs ([External Libraries](/docs/features/libraries) scanning, Database Dump) are configured in their own sections in System Settings.
:::

View File

@@ -20,6 +20,7 @@ Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an i
Before enabling OAuth in Immich, a new client application needs to be configured in the 3rd-party authentication server. While the specifics of this setup vary from provider to provider, the general approach should be the same.
1. Create a new (Client) Application
1. The **Provider** type should be `OpenID Connect` or `OAuth2`
2. The **Client type** should be `Confidential`
3. The **Application** type should be `Web`
@@ -28,6 +29,7 @@ Before enabling OAuth in Immich, a new client application needs to be configured
2. Configure Redirect URIs/Origins
The **Sign-in redirect URIs** should include:
- `app.immich:///oauth-callback` - for logging in with OAuth from the [Mobile App](/docs/features/mobile-app.mdx)
- `http://DOMAIN:PORT/auth/login` - for logging in with OAuth from the Web Client
- `http://DOMAIN:PORT/user-settings` - for manually linking OAuth in the Web Client
@@ -35,17 +37,21 @@ Before enabling OAuth in Immich, a new client application needs to be configured
Redirect URIs should contain all the domains you will be using to access Immich. Some examples include:
Mobile
- `app.immich:///oauth-callback` (You **MUST** include this for iOS and Android mobile apps to work properly)
Localhost
- `http://localhost:2283/auth/login`
- `http://localhost:2283/user-settings`
Local IP
- `http://192.168.0.200:2283/auth/login`
- `http://192.168.0.200:2283/user-settings`
Hostname
- `https://immich.example.com/auth/login`
- `https://immich.example.com/user-settings`
@@ -62,7 +68,6 @@ Once you have a new OAuth client application configured, Immich can be configure
| Scope | string | openid email profile | Full list of scopes to send with the request (space delimited) |
| Signing Algorithm | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |

View File

@@ -199,11 +199,13 @@ To use your SSH key for commit signing, see the [GitHub guide on SSH commit sign
When the Dev Container starts, it automatically:
1. **Runs post-create script** (`container-server-post-create.sh`):
- Adjusts file permissions for the `node` user
- Installs dependencies: `npm install` in all packages
- Builds TypeScript SDK: `npm run build` in `open-api/typescript-sdk`
2. **Starts development servers** via VS Code tasks:
- `Immich API Server (Nest)` - API server with hot-reloading on port 2283
- `Immich Web Server (Vite)` - Web frontend with hot-reloading on port 3000
- Both servers watch for file changes and recompile automatically
@@ -333,12 +335,14 @@ make install-all # Install all dependencies
The Dev Container is pre-configured for debugging:
1. **API Server Debugging**:
- Set breakpoints in VS Code
- Press `F5` or use "Run and Debug" panel
- Select "Attach to Server" configuration
- Debug port: 9231
2. **Worker Debugging**:
- Use "Attach to Workers" configuration
- Debug port: 9230
@@ -424,6 +428,7 @@ While the Dev Container focuses on server and web development, you can connect m
```
2. **Configure mobile app**:
- Server URL: `http://YOUR_IP:2283/api`
- Ensure firewall allows port 2283

View File

@@ -2,7 +2,7 @@
Folder view provides an additional view besides the timeline that is similar to a file explorer. It allows you to navigate through the folders and files in the library. This feature is handy for a highly curated and customized external library or a nicely configured storage template.
You can enable this feature under [`Account Settings > Features > Folders`](https://my.immich.app/user-settings?isOpen=feature+folders)
You can enable this feature under [`Account Settings > Features > Folder View`](https://my.immich.app/user-settings?isOpen=feature+folders)
## Enable folder view

View File

@@ -56,7 +56,7 @@ Internally, Immich uses the [glob](https://www.npmjs.com/package/glob) package t
### Automatic watching (EXPERIMENTAL)
This feature is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan.
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan.
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
@@ -112,7 +112,7 @@ _Remember to run `docker compose up -d` to register the changes. Make sure you c
These actions must be performed by the Immich administrator.
- Click on your avatar in the upper right corner
- Click on your avatar on the upper right corner
- Click on Administration -> External Libraries
- Click on Create an external library…
- Select which user owns the library, this can not be changed later
@@ -159,7 +159,9 @@ Within seconds, the assets from the old-pics and videos folders should show up i
Folder view provides an additional view besides the timeline that is similar to a file explorer. It allows you to navigate through the folders and files in the library. This feature is handy for a highly curated and customized external library or a nicely configured storage template.
You can enable this feature under [`Account Settings > Features > Folders`](https://my.immich.app/user-settings?isOpen=feature+folders)
You can enable this feature under [`Account Settings > Features > Folder View`](https://my.immich.app/user-settings?isOpen=feature+folders)
The UI is currently only available for the web; mobile will come in a subsequent release.
<img src={require('./img/folder-view-1.webp').default} width="100%" title='Folder-view' />
@@ -169,7 +171,7 @@ You can enable this feature under [`Account Settings > Features > Folders`](http
Only an admin can do this.
:::
You can define a custom interval for the trigger external library rescan under Administration -> Settings -> External Library.
You can define a custom interval for the trigger external library rescan under Administration -> Settings -> Library.
You can set the scanning interval using the preset or cron format. For more information you can refer to [Crontab Guru](https://crontab.guru/).
<img src={require('./img/library-custom-scan-interval.webp').default} width="75%" title='Set custom scan interval for external library' />

View File

@@ -16,7 +16,7 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
| `HEIC` | `.heic` | :white_check_mark: | |
| `HEIF` | `.heif` | :white_check_mark: | |
| `JPEG 2000` | `.jp2` | :white_check_mark: | |
| `JPEG` | `.jpeg` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
| `JPEG` | `.webp` `.jpg` `.jpe` `.insp` | :white_check_mark: | |
| `JPEG XL` | `.jxl` | :white_check_mark: | |
| `PNG` | `.png` | :white_check_mark: | |
| `PSD` | `.psd` | :white_check_mark: | Adobe Photoshop |

View File

@@ -41,7 +41,7 @@ In the Immich web UI:
- Click Add path
<img src={require('./img/add-path-button.webp').default} width="50%" title="Add Path button" />
- Enter **/home/user/photos1** as the path and click Add
- Enter **/usr/src/app/external** as the path and click Add
<img src={require('./img/add-path-field.webp').default} width="50%" title="Add Path field" />
- Save the new path

View File

@@ -52,9 +52,9 @@ REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
### Local
# Backup Immich database
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres > "$UPLOAD_LOCATION"/database-backup/immich-database.sql
# For deduplicating backup programs such as Borg or Restic, compressing the content can increase backup size by making it harder to deduplicate. If you are using a different program or still prefer to compress, you can use the following command instead:
# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=<DB_USERNAME> | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgres | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz
### Append to local Borg repository
borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/

View File

@@ -72,25 +72,22 @@ Information on the current workers can be found [here](/docs/administration/jobs
## Database
| Variable | Description | Default | Containers |
| :---------------------------------- | :------------------------------------------------------------------------------------- | :--------: | :----------------------------- |
| `DB_URL` | Database URL | | server |
| `DB_HOSTNAME` | Database host | `database` | server |
| `DB_PORT` | Database port | `5432` | server |
| `DB_USERNAME` | Database user | `postgres` | server, database<sup>\*1</sup> |
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
| Variable | Description | Default | Containers |
| :---------------------------------- | :--------------------------------------------------------------------------- | :--------: | :----------------------------- |
| `DB_URL` | Database URL | | server |
| `DB_HOSTNAME` | Database host | `database` | server |
| `DB_PORT` | Database port | `5432` | server |
| `DB_USERNAME` | Database user | `postgres` | server, database<sup>\*1</sup> |
| `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
\*2: If not provided, the appropriate extension to use is auto-detected at startup by introspecting the database. When multiple extensions are installed, the order of preference is VectorChord, pgvecto.rs, pgvector.
\*3: Uses either [`postgresql.ssd.conf`](https://github.com/immich-app/base-images/blob/main/postgres/postgresql.ssd.conf) or [`postgresql.hdd.conf`](https://github.com/immich-app/base-images/blob/main/postgres/postgresql.hdd.conf) which mainly controls the Postgres `effective_io_concurrency` setting to allow for concurrenct IO on SSDs and sequential IO on HDDs.
:::info
All `DB_` variables must be provided to all Immich workers, including `api` and `microservices`.

View File

@@ -39,8 +39,8 @@ alt="Dot Env Example"
/>
- Change the default `DB_PASSWORD`, and add custom database connection information if necessary.
- Change `DB_DATA_LOCATION` to a folder (absolute path) where the database will be saved to disk.
- Change `UPLOAD_LOCATION` to a folder (absolute path) where media (uploaded and generated) will be stored.
- Change `DB_DATA_LOCATION` to a folder where the database will be saved to disk.
- Change `UPLOAD_LOCATION` to a folder where media (uploaded and generated) will be stored.
11. Click on "**Deploy the stack**".

View File

@@ -75,6 +75,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:
- `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION`
- `DB_DATA_LOCATION`: Change this to use an Unraid share (preferably a cache pool, e.g. `/mnt/user/appdata/postgresql/data`). This uses the `appdata` share. Do also create the `postgresql` folder, by running `mkdir /mnt/user/{share_location}/postgresql/data`. If left at default it will try to use Unraid's `/boot/config/plugins/compose.manager/projects/[stack_name]/postgres` folder which it doesn't have permissions to, resulting in this container continuously restarting.

1531
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,8 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "~3.8.0",
"@docusaurus/preset-classic": "~3.8.0",
"@docusaurus/theme-common": "~3.8.0",
"@docusaurus/core": "~3.7.0",
"@docusaurus/preset-classic": "~3.7.0",
"@mdi/js": "^7.3.67",
"@mdi/react": "^1.6.1",
"@mdx-js/react": "^3.0.0",
@@ -27,7 +26,6 @@
"clsx": "^2.0.0",
"docusaurus-lunr-search": "^3.3.2",
"docusaurus-preset-openapi": "^0.7.5",
"lunr": "^2.3.9",
"postcss": "^8.4.25",
"prism-react-renderer": "^2.3.1",
"raw-loader": "^4.0.2",
@@ -37,7 +35,7 @@
"url": "^0.11.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "~3.8.0",
"@docusaurus/module-type-aliases": "~3.7.0",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.7.0",
"prettier": "^3.2.4",
@@ -59,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "22.17.0"
"node": "22.16.0"
}
}

View File

@@ -58,12 +58,6 @@ const guides: CommunityGuidesProps[] = [
description: 'Access Immich with an end-to-end encrypted connection.',
url: 'https://meshnet.nordvpn.com/how-to/remote-files-media-access/immich-remote-access',
},
{
title: 'Trust Self Signed Certificates with Immich - OAuth Setup',
description:
'Set up Certificate Authority trust with Immich, and your private OAuth2/OpenID service, while using a private CA for HTTPS commication.',
url: 'https://github.com/immich-app/immich/discussions/18614',
},
];
function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element {

View File

@@ -85,7 +85,6 @@ import React from 'react';
import { Item, Timeline } from '../components/timeline';
const releases = {
'v1.135.0': new Date(2025, 5, 18),
'v1.133.0': new Date(2025, 4, 21),
'v1.130.0': new Date(2025, 2, 25),
'v1.127.0': new Date(2025, 1, 26),
@@ -197,6 +196,14 @@ const roadmap: Item[] = [
description: 'Automate tasks with workflows',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for users and api keys',
getDateLabel: () => 'Planned for 2025',
},
{
done: false,
icon: mdiImageEdit,
@@ -232,26 +239,12 @@ const roadmap: Item[] = [
];
const milestones: Item[] = [
{
icon: mdiStar,
iconColor: 'gold',
title: '70,000 Stars',
description: 'Reached 70K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 6, 9)),
},
withRelease({
icon: mdiTableKey,
iconColor: 'gray',
title: 'Fine grained access controls',
description: 'Granular access controls for api keys',
release: 'v1.135.0',
}),
withRelease({
icon: mdiCast,
iconColor: 'aqua',
title: 'Google Cast (web and mobile)',
title: 'Google Cast (web)',
description: 'Cast assets to Google Cast/Chromecast compatible devices',
release: 'v1.135.0',
release: 'v1.133.0',
}),
withRelease({
icon: mdiLockOutline,

View File

@@ -1,5 +1,4 @@
/docs /docs/overview/welcome 307
/docs/ /docs/overview/welcome 307
/docs /docs/overview/introduction 307
/docs/mobile-app-beta-program /docs/features/mobile-app 307
/docs/contribution-guidelines /docs/overview/support-the-project#contributing 307
/docs/install /docs/install/docker-compose 307
@@ -31,4 +30,4 @@
/docs/guides/api-album-sync /docs/community-projects 307
/docs/guides/remove-offline-files /docs/community-projects 307
/milestones /roadmap 307
/docs/overview/introduction /docs/overview/welcome 307
/docs/overview/introduction /docs/overview/welcome 307

View File

@@ -1,16 +1,4 @@
[
{
"label": "v1.135.3",
"url": "https://v1.135.3.archive.immich.app"
},
{
"label": "v1.135.2",
"url": "https://v1.135.2.archive.immich.app"
},
{
"label": "v1.135.1",
"url": "https://v1.135.1.archive.immich.app"
},
{
"label": "v1.135.0",
"url": "https://v1.135.0.archive.immich.app"

View File

@@ -1 +1 @@
22.17.0
22.16.0

View File

@@ -36,10 +36,10 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:03fd052257735b41cd19f3d8ae9782926bf9b704fb6a9dc5e29f9ccfbe8827f0
image: redis:6.2-alpine@sha256:3211c33a618c457e5d241922c975dbc4f446d0bdb2dc75694f5573ef8e2d01fa
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:3aef84a0a4fabbda17ef115c3019ba0c914ec73e9f6e59203674322d858b8eea
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:9c704fb49ce27549df00f1b096cc93f8b0c959ef087507704d74954808f78a82
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres

1248
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.135.3",
"version": "1.135.0",
"description": "",
"main": "index.js",
"type": "module",
@@ -24,9 +24,8 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.15.33",
"@types/node": "^22.15.31",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
@@ -45,7 +44,6 @@
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.0",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@@ -54,6 +52,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "22.17.0"
"node": "22.16.0"
}
}

View File

@@ -7,7 +7,6 @@ import {
ReactionType,
createActivity as create,
createAlbum,
removeAssetFromAlbum,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@@ -343,36 +342,5 @@ describe('/activities', () => {
expect(status).toBe(204);
});
it('should return empty list when asset is removed', async () => {
const album3 = await createAlbum(
{
createAlbumDto: {
albumName: 'Album 3',
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like });
await removeAssetFromAlbum(
{
id: album3.id,
bulkIdsDto: {
ids: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) },
);
const { status, body } = await request(app)
.get('/activities')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toEqual([]);
});
});
});

View File

@@ -20,7 +20,7 @@ describe('/api-keys', () => {
});
beforeEach(async () => {
await utils.resetDatabase(['api_key']);
await utils.resetDatabase(['api_keys']);
});
describe('POST /api-keys', () => {

View File

@@ -15,7 +15,6 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import sharp from 'sharp';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
@@ -41,40 +40,6 @@ const today = DateTime.fromObject({
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
const createTestImageWithExif = async (filename: string, exifData: Record<string, any>) => {
// Generate unique color to ensure different checksums for each image
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
// Create a 100x100 solid color JPEG using Sharp
const imageBytes = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
// Add random suffix to filename to avoid collisions
const uniqueFilename = filename.replace('.jpg', `-${randomBytes(4).toString('hex')}.jpg`);
const filepath = join(tempDir, uniqueFilename);
await writeFile(filepath, imageBytes);
// Filter out undefined values before writing EXIF
const cleanExifData = Object.fromEntries(Object.entries(exifData).filter(([, value]) => value !== undefined));
await exiftool.write(filepath, cleanExifData);
// Re-read the image bytes after EXIF has been written
const finalImageBytes = await readFile(filepath);
return { filepath, imageBytes: finalImageBytes, filename: uniqueFilename };
};
describe('/asset', () => {
let admin: LoginResponseDto;
let websocket: Socket;
@@ -1225,411 +1190,6 @@ describe('/asset', () => {
});
});
describe('EXIF metadata extraction', () => {
describe('Additional date tag extraction', () => {
describe('Date-time vs time-only tag handling', () => {
it('should fall back to file timestamps when only time-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('time-only-fallback.jpg', {
TimeCreated: '2023:11:15 14:30:00', // Time-only tag, should not be used for dateTimeOriginal
// Exclude all date-time tags to force fallback to file timestamps
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
SonyDateTime2: undefined,
GPSDateStamp: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should prefer DateTimeOriginal over time-only tags', async () => {
const { imageBytes, filename } = await createTestImageWithExif('datetime-over-time.jpg', {
DateTimeOriginal: '2023:10:10 10:00:00', // Should be preferred
TimeCreated: '2023:11:15 14:30:00', // Should be ignored (time-only)
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal, not TimeCreated
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
describe('GPSDateTime tag extraction', () => {
it('should extract GPSDateTime with GPS coordinates', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datetime.jpg', {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: 37.7749,
GPSLongitude: -122.4194,
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(37.7749, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-122.4194, 4);
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T12:30:00.000Z').getTime(),
);
});
});
describe('CreateDate tag extraction', () => {
it('should extract CreateDate when available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('create-date.jpg', {
CreateDate: '2023:11:15 10:30:00',
// Exclude other higher priority date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-11-15T10:30:00.000Z').getTime(),
);
});
});
describe('GPSDateStamp tag extraction', () => {
it('should fall back to file timestamps when only date-only tags are available', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp.jpg', {
GPSDateStamp: '2023:11:15', // Date-only tag, should not be used for dateTimeOriginal
// Note: NOT including GPSTimeStamp to avoid automatic GPSDateTime creation
GPSLatitude: 51.5074,
GPSLongitude: -0.1278,
// Explicitly exclude all testable date-time tags to force fallback to file timestamps
DateTimeOriginal: undefined,
CreateDate: undefined,
CreationDate: undefined,
GPSDateTime: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
expect(assetInfo.exifInfo?.latitude).toBeCloseTo(51.5074, 4);
expect(assetInfo.exifInfo?.longitude).toBeCloseTo(-0.1278, 4);
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
});
/*
* NOTE: The following EXIF date tags are NOT effectively usable with JPEG test files:
*
* NOT WRITABLE to JPEG:
* - MediaCreateDate: Can be read from video files but not written to JPEG
* - DateTimeCreated: Read-only tag in JPEG format
* - DateTimeUTC: Cannot be written to JPEG files
* - SonyDateTime2: Proprietary Sony tag, not writable to JPEG
* - SubSecMediaCreateDate: Tag not defined for JPEG format
* - SourceImageCreateTime: Non-standard insta360 tag, not writable to JPEG
*
* WRITABLE but NOT READABLE from JPEG:
* - SubSecDateTimeOriginal: Can be written but not read back from JPEG
* - SubSecCreateDate: Can be written but not read back from JPEG
*
* EFFECTIVELY TESTABLE TAGS (writable and readable):
* - DateTimeOriginal ✓
* - CreateDate ✓
* - CreationDate ✓
* - GPSDateTime ✓
*
* The metadata service correctly handles non-readable tags and will fall back to
* file timestamps when only non-readable tags are present.
*/
describe('Date tag priority order', () => {
it('should respect the complete date tag priority order', async () => {
// Test cases using only EFFECTIVELY TESTABLE tags (writable AND readable from JPEG)
const testCases = [
{
name: 'DateTimeOriginal has highest priority among testable tags',
exifData: {
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-04-04T04:00:00.000Z',
},
{
name: 'CreateDate when DateTimeOriginal missing',
exifData: {
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-05-05T05:00:00.000Z',
},
{
name: 'CreationDate when standard EXIF tags missing',
exifData: {
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
},
expectedDate: '2023-07-07T07:00:00.000Z',
},
{
name: 'GPSDateTime when no other testable date tags present',
exifData: {
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
Make: 'SONY',
},
expectedDate: '2023-10-10T10:00:00.000Z',
},
];
for (const testCase of testCases) {
const { imageBytes, filename } = await createTestImageWithExif(
`${testCase.name.replaceAll(/\s+/g, '-').toLowerCase()}.jpg`,
testCase.exifData,
);
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal, `Failed for: ${testCase.name}`).toBeDefined();
expect(
new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime(),
`Date mismatch for: ${testCase.name}`,
).toBe(new Date(testCase.expectedDate).getTime());
}
});
});
describe('Edge cases for date tag handling', () => {
it('should fall back to file timestamps with GPSDateStamp alone', async () => {
const { imageBytes, filename } = await createTestImageWithExif('gps-datestamp-only.jpg', {
GPSDateStamp: '2023:08:08', // Date-only tag, should not be used for dateTimeOriginal
// Intentionally no GPSTimeStamp
// Exclude all other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
SubSecMediaCreateDate: undefined,
CreateDate: undefined,
MediaCreateDate: undefined,
CreationDate: undefined,
DateTimeCreated: undefined,
TimeCreated: undefined,
GPSDateTime: undefined,
DateTimeUTC: undefined,
});
const oldDate = new Date('2020-01-01T00:00:00.000Z');
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
fileCreatedAt: oldDate.toISOString(),
fileModifiedAt: oldDate.toISOString(),
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should fall back to file timestamps, which we set to 2020-01-01
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2020-01-01T00:00:00.000Z').getTime(),
);
});
it('should handle all testable date tags present to verify complete priority order', async () => {
const { imageBytes, filename } = await createTestImageWithExif('all-testable-date-tags.jpg', {
// All TESTABLE date tags to JPEG format (writable AND readable)
DateTimeOriginal: '2023:04:04 04:00:00', // TESTABLE - highest priority among readable tags
CreateDate: '2023:05:05 05:00:00', // TESTABLE
CreationDate: '2023:07:07 07:00:00', // TESTABLE
GPSDateTime: '2023:10:10 10:00:00', // TESTABLE
// Note: Excluded non-testable tags:
// SubSec tags: writable but not readable from JPEG
// Non-writable tags: MediaCreateDate, DateTimeCreated, DateTimeUTC, SonyDateTime2, etc.
// Time-only/date-only tags: already excluded from EXIF_DATE_TAGS
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use DateTimeOriginal as it has the highest priority among testable tags
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-04-04T04:00:00.000Z').getTime(),
);
});
it('should use CreationDate when SubSec tags are missing', async () => {
const { imageBytes, filename } = await createTestImageWithExif('creation-date-priority.jpg', {
CreationDate: '2023:07:07 07:00:00', // WRITABLE
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE
// Note: DateTimeCreated, DateTimeUTC, SonyDateTime2 are NOT writable to JPEG
// Note: TimeCreated and GPSDateStamp are excluded from EXIF_DATE_TAGS (time-only/date-only)
// Exclude SubSec and standard EXIF tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should use CreationDate when available
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-07-07T07:00:00.000Z').getTime(),
);
});
it('should skip invalid date formats and use next valid tag', async () => {
const { imageBytes, filename } = await createTestImageWithExif('invalid-date-handling.jpg', {
// Note: Testing invalid date handling with only WRITABLE tags
GPSDateTime: '2023:10:10 10:00:00', // WRITABLE - Valid date
CreationDate: '2023:13:13 13:00:00', // WRITABLE - Valid date
// Note: TimeCreated excluded (time-only), DateTimeCreated not writable to JPEG
// Exclude other date tags
SubSecDateTimeOriginal: undefined,
DateTimeOriginal: undefined,
SubSecCreateDate: undefined,
CreateDate: undefined,
});
const asset = await utils.createAsset(admin.accessToken, {
assetData: {
filename,
bytes: imageBytes,
},
});
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
const assetInfo = await getAssetInfo({ id: asset.id }, { headers: asBearerAuth(admin.accessToken) });
expect(assetInfo.exifInfo?.dateTimeOriginal).toBeDefined();
// Should skip invalid dates and use the first valid one (GPSDateTime)
expect(new Date(assetInfo.exifInfo!.dateTimeOriginal!).getTime()).toBe(
new Date('2023-10-10T10:00:00.000Z').getTime(),
);
});
});
});
});
describe('POST /assets/exist', () => {
it('ignores invalid deviceAssetIds', async () => {
const response = await utils.checkExistingAssets(user1.accessToken, {

View File

@@ -0,0 +1,146 @@
import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto } from 'src/fixtures';
import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
const { email, password } = signupDto.admin;
describe(`/auth/admin-sign-up`, () => {
beforeEach(async () => {
await utils.resetDatabase();
});
describe('POST /auth/admin-sign-up', () => {
it(`should sign up the admin`, async () => {
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(201);
expect(body).toEqual(signupResponseDto.admin);
});
it('should not allow a second admin to sign up', async () => {
await signUpAdmin({ signUpDto: signupDto.admin });
const { status, body } = await request(app).post('/auth/admin-sign-up').send(signupDto.admin);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
});
});
});
describe('/auth/*', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
await utils.resetDatabase();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(app).post('/auth/login').send({ email, password: 'incorrect' });
expect(status).toBe(401);
expect(body).toEqual(errorDto.incorrectLogin);
});
it('should accept a correct password', async () => {
const { status, body, headers } = await request(app).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseDto.admin);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
`immich_access_token=${token}`,
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(app)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require the current password', async () => {
const { status, body } = await request(app)
.post(`/auth/change-password`)
.send({ password: 'wrong-password', newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(app)
.post(`/auth/change-password`)
.send({ password, newPassword: 'Password1234' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
await login({
loginCredentialDto: {
email: 'admin@immich.cloud',
password: 'Password1234',
},
});
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(app)
.post(`/auth/logout`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
});
});
});

View File

@@ -6,7 +6,7 @@ import {
createMemory,
getMemory,
} from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
@@ -17,6 +17,7 @@ describe('/memories', () => {
let user: LoginResponseDto;
let adminAsset: AssetMediaResponseDto;
let userAsset1: AssetMediaResponseDto;
let userAsset2: AssetMediaResponseDto;
let userMemory: MemoryResponseDto;
beforeAll(async () => {
@@ -24,9 +25,10 @@ describe('/memories', () => {
admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
[adminAsset, userAsset1] = await Promise.all([
[adminAsset, userAsset1, userAsset2] = await Promise.all([
utils.createAsset(admin.accessToken),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
userMemory = await createMemory(
{
@@ -41,7 +43,121 @@ describe('/memories', () => {
);
});
describe('GET /memories', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/memories');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('POST /memories', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/memories');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should validate data when type is on this day', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: {},
memoryAt: new Date(2021).toISOString(),
});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
);
});
it('should create a new memory', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
});
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
type: 'on_this_day',
data: { year: 2021 },
createdAt: expect.any(String),
updatedAt: expect.any(String),
isSaved: false,
memoryAt: expect.any(String),
ownerId: user.userId,
assets: [],
});
});
it('should create a new memory (with assets)', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
assetIds: [userAsset1.id, userAsset2.id],
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
assets: expect.arrayContaining([
expect.objectContaining({ id: userAsset1.id }),
expect.objectContaining({ id: userAsset2.id }),
]),
});
expect(body.assets).toHaveLength(2);
});
it('should create a new memory and ignore assets the user does not have access to', async () => {
const { status, body } = await request(app)
.post('/memories')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({
type: 'on_this_day',
data: { year: 2021 },
memoryAt: new Date(2021).toISOString(),
assetIds: [userAsset1.id, adminAsset.id],
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
assets: [expect.objectContaining({ id: userAsset1.id })],
});
expect(body.assets).toHaveLength(1);
});
});
describe('GET /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/memories/${uuidDto.invalid}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.get(`/memories/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/memories/${userMemory.id}`)
@@ -60,6 +176,22 @@ describe('/memories', () => {
});
describe('PUT /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/memories/${uuidDto.invalid}`).send({ isSaved: true });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put(`/memories/${uuidDto.invalid}`)
.send({ isSaved: true })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}`)
@@ -86,6 +218,23 @@ describe('/memories', () => {
});
describe('PUT /memories/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put(`/memories/${uuidDto.invalid}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
@@ -95,6 +244,15 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission);
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
.send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should require asset access', async () => {
const { status, body } = await request(app)
.put(`/memories/${userMemory.id}/assets`)
@@ -121,6 +279,23 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
.send({ ids: [userAsset1.id] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${uuidDto.invalid}/assets`)
.send({ ids: [userAsset1.id] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
@@ -130,6 +305,15 @@ describe('/memories', () => {
expect(body).toEqual(errorDto.noPermission);
});
it('should require a valid asset id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
.send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
});
it('should only remove assets in the memory', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}/assets`)
@@ -156,6 +340,21 @@ describe('/memories', () => {
});
describe('DELETE /memories/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/memories/${uuidDto.invalid}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.delete(`/memories/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.delete(`/memories/${userMemory.id}`)

View File

@@ -227,21 +227,6 @@ describe(`/oauth`, () => {
expect(user.storageLabel).toBe('user-username');
});
it('should set the admin status from a role claim', async () => {
const callbackParams = await loginWithOAuth(OAuthUser.WITH_ROLE);
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
userId: expect.any(String),
userEmail: 'oauth-with-role@immich.app',
isAdmin: true,
});
const user = await getMyUser({ headers: asBearerAuth(body.accessToken) });
expect(user.isAdmin).toBe(true);
});
it('should work with RS256 signed tokens', async () => {
await setupOAuth(admin.accessToken, {
enabled: true,

View File

@@ -14,11 +14,7 @@ describe('/people', () => {
let nameAlicePerson: PersonResponseDto;
let nameBobPerson: PersonResponseDto;
let nameCharliePerson: PersonResponseDto;
let nameNullPerson4Assets: PersonResponseDto;
let nameNullPerson3Assets: PersonResponseDto;
let nameNullPerson1Asset: PersonResponseDto;
let nameBillPersonFavourite: PersonResponseDto;
let nameFreddyPersonFavourite: PersonResponseDto;
let nameNullPerson: PersonResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
@@ -31,11 +27,7 @@ describe('/people', () => {
nameCharliePerson,
nameBobPerson,
nameAlicePerson,
nameNullPerson4Assets,
nameNullPerson3Assets,
nameNullPerson1Asset,
nameBillPersonFavourite,
nameFreddyPersonFavourite,
nameNullPerson,
] = await Promise.all([
utils.createPerson(admin.accessToken, {
name: 'visible_person',
@@ -60,26 +52,11 @@ describe('/people', () => {
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: '',
}),
utils.createPerson(admin.accessToken, {
name: 'Bill',
isFavorite: true,
}),
utils.createPerson(admin.accessToken, {
name: 'Freddy',
isFavorite: true,
}),
]);
const asset1 = await utils.createAsset(admin.accessToken);
const asset2 = await utils.createAsset(admin.accessToken);
const asset3 = await utils.createAsset(admin.accessToken);
const asset4 = await utils.createAsset(admin.accessToken);
await Promise.all([
utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }),
@@ -87,27 +64,15 @@ describe('/people', () => {
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }),
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets
utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }),
// Named persons
utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset
utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets
utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset
// Null-named person 4 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }),
utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets
// Null-named person 3 assets
utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }),
utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets
// Null-named person 1 asset
utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }),
// Favourite People
utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }),
utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }),
// Null-named person
utils.createFace({ assetId: asset1.id, personId: nameNullPerson.id }),
utils.createFace({ assetId: asset2.id, personId: nameNullPerson.id }), // 2 assets
]);
});
@@ -122,19 +87,15 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: false,
total: 11,
total: 7,
hidden: 1,
people: [
expect.objectContaining({ name: 'Freddy' }),
expect.objectContaining({ name: 'Bill' }),
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Charlie' }),
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }),
expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
expect.objectContaining({ name: 'hidden_person' }), // Should really be before the null names
expect.objectContaining({ name: 'hidden_person' }),
],
});
});
@@ -144,21 +105,17 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body.hasNextPage).toBe(false);
expect(body.total).toBe(11); // All persons
expect(body.total).toBe(7); // All persons
expect(body.hidden).toBe(1); // 'hidden_person'
const people = body.people as PersonResponseDto[];
expect(people.map((p) => p.id)).toEqual([
nameFreddyPersonFavourite.id, // name: 'Freddy', count: 2
nameBillPersonFavourite.id, // name: 'Bill', count: 1
multipleAssetsPerson.id, // name: 'multiple_assets_person', count: 3
nameBobPerson.id, // name: 'Bob', count: 2
nameAlicePerson.id, // name: 'Alice', count: 1
nameCharliePerson.id, // name: 'Charlie', count: 1
visiblePerson.id, // name: 'visible_person', count: 1
nameNullPerson4Assets.id, // name: '', count: 4
nameNullPerson3Assets.id, // name: '', count: 3
]);
expect(people.some((p) => p.id === hiddenPerson.id)).toBe(false);
@@ -170,18 +127,14 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: false,
total: 11,
total: 7,
hidden: 1,
people: [
expect.objectContaining({ name: 'Freddy' }),
expect.objectContaining({ name: 'Bill' }),
expect.objectContaining({ name: 'multiple_assets_person' }),
expect.objectContaining({ name: 'Bob' }),
expect.objectContaining({ name: 'Alice' }),
expect.objectContaining({ name: 'Charlie' }),
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ id: nameNullPerson4Assets.id, name: '' }),
expect.objectContaining({ id: nameNullPerson3Assets.id, name: '' }),
],
});
});
@@ -195,9 +148,9 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toEqual({
hasNextPage: true,
total: 11,
total: 7,
hidden: 1,
people: [expect.objectContaining({ name: 'Alice' })],
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
});

View File

@@ -117,25 +117,8 @@ describe('/shared-links', () => {
const resp = await request(shareUrl).get(`/${linkWithAssets.key}`);
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(`<meta property="og:image" content="http://127.0.0.1:2285`);
});
it('should fall back to my.immich.app og:image meta tag for shared asset if Host header is not present', async () => {
const resp = await request(shareUrl).get(`/${linkWithAssets.key}`).set('Host', '');
expect(resp.status).toBe(200);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).toContain(`<meta property="og:image" content="https://my.immich.app`);
});
it('should return 404 for an invalid shared link', async () => {
const resp = await request(shareUrl).get(`/invalid-key`);
expect(resp.status).toBe(404);
expect(resp.header['content-type']).toContain('text/html');
expect(resp.text).not.toContain(`og:type`);
expect(resp.text).not.toContain(`og:title`);
expect(resp.text).not.toContain(`og:description`);
expect(resp.text).not.toContain(`og:image`);
});
});
describe('GET /shared-links', () => {

View File

@@ -15,6 +15,12 @@ describe('/system-config', () => {
});
describe('PUT /system-config', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put('/system-config');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should always return the new config', async () => {
const config = await getSystemConfig(admin.accessToken);

View File

@@ -37,7 +37,7 @@ describe('/tags', () => {
beforeEach(async () => {
// tagging assets eventually triggers metadata extraction which can impact other tests
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.resetDatabase(['tag']);
await utils.resetDatabase(['tags']);
});
describe('POST /tags', () => {

View File

@@ -0,0 +1,230 @@
import {
AssetMediaResponseDto,
AssetVisibility,
LoginResponseDto,
SharedLinkType,
TimeBucketAssetResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
// TODO this should probably be a test util function
const today = DateTime.fromObject({
year: 2023,
month: 11,
day: 3,
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
describe('/timeline', () => {
let admin: LoginResponseDto;
let user: LoginResponseDto;
let timeBucketUser: LoginResponseDto;
let user1Assets: AssetMediaResponseDto[];
let user2Assets: AssetMediaResponseDto[];
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[user, timeBucketUser] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.create('1')),
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
]);
user1Assets = await Promise.all([
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken, {
isFavorite: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
assetData: { filename: 'example.mp4' },
}),
utils.createAsset(user.accessToken),
utils.createAsset(user.accessToken),
]);
user2Assets = await Promise.all([
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-12').toISOString() }),
]);
await utils.deleteAssets(timeBucketUser.accessToken, [user2Assets[4].id]);
});
describe('GET /timeline/buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/buckets');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get time buckets by month', async () => {
const { status, body } = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01' },
{ count: 1, timeBucket: '1970-01-01' },
]),
);
});
it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user.accessToken, {
type: SharedLinkType.Individual,
assetIds: user1Assets.map(({ id }) => id),
});
const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, visibility: AssetVisibility.Archive });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ withPartners: true, visibility: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and favorite', async () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, isFavorite: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ withPartners: true, isFavorite: false });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and trash', async () => {
const req = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ withPartners: true, isTrashed: true });
expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest());
});
});
describe('GET /timeline/bucket', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/timeline/bucket').query({
timeBucket: '1900-01-01',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should handle 5 digit years', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.query({ timeBucket: '012345-01-01' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
city: [],
country: [],
duration: [],
id: [],
visibility: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
fileCreatedAt: [],
localOffsetHours: [],
ownerId: [],
projectionType: [],
ratio: [],
status: [],
thumbhash: [],
});
});
// TODO enable date string validation while still accepting 5 digit years
// it('should fail if time bucket is invalid', async () => {
// const { status, body } = await request(app)
// .get('/timeline/bucket')
// .set('Authorization', `Bearer ${user.accessToken}`)
// .query({ timeBucket: 'foo' });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest);
// });
it('should return time bucket', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ timeBucket: '1970-02-10' });
expect(status).toBe(200);
expect(body).toEqual({
city: [],
country: [],
duration: [],
id: [],
visibility: [],
isFavorite: [],
isImage: [],
isTrashed: [],
livePhotoVideoId: [],
fileCreatedAt: [],
localOffsetHours: [],
ownerId: [],
projectionType: [],
ratio: [],
status: [],
thumbhash: [],
});
});
it('should return time bucket in trash', async () => {
const { status, body } = await request(app)
.get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ timeBucket: '1970-02-01T00:00:00.000Z', isTrashed: true });
expect(status).toBe(200);
const timeBucket: TimeBucketAssetResponseDto = body;
expect(timeBucket.isTrashed).toEqual([true]);
});
});
});

View File

@@ -97,7 +97,7 @@ describe(`immich upload`, () => {
});
beforeEach(async () => {
await utils.resetDatabase(['asset', 'album']);
await utils.resetDatabase(['assets', 'albums']);
});
describe(`immich upload /path/to/file.jpg`, () => {

View File

@@ -1,178 +0,0 @@
#!/usr/bin/env node
/**
* Script to generate test images with additional EXIF date tags
* This creates actual JPEG images with embedded metadata for testing
* Images are generated into e2e/test-assets/metadata/dates/
*/
import { execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
interface TestImage {
filename: string;
description: string;
exifTags: Record<string, string>;
}
const testImages: TestImage[] = [
{
filename: 'time-created.jpg',
description: 'Image with TimeCreated tag',
exifTags: {
TimeCreated: '2023:11:15 14:30:00',
Make: 'Canon',
Model: 'EOS R5',
},
},
{
filename: 'gps-datetime.jpg',
description: 'Image with GPSDateTime and coordinates',
exifTags: {
GPSDateTime: '2023:11:15 12:30:00Z',
GPSLatitude: '37.7749',
GPSLongitude: '-122.4194',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'datetime-utc.jpg',
description: 'Image with DateTimeUTC tag',
exifTags: {
DateTimeUTC: '2023:11:15 10:30:00',
Make: 'Nikon',
Model: 'D850',
},
},
{
filename: 'gps-datestamp.jpg',
description: 'Image with GPSDateStamp and GPSTimeStamp',
exifTags: {
GPSDateStamp: '2023:11:15',
GPSTimeStamp: '08:30:00',
GPSLatitude: '51.5074',
GPSLongitude: '-0.1278',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
{
filename: 'sony-datetime2.jpg',
description: 'Sony camera image with SonyDateTime2 tag',
exifTags: {
SonyDateTime2: '2023:11:15 06:30:00',
Make: 'SONY',
Model: 'ILCE-7RM5',
},
},
{
filename: 'date-priority-test.jpg',
description: 'Image with multiple date tags to test priority',
exifTags: {
SubSecDateTimeOriginal: '2023:01:01 01:00:00',
DateTimeOriginal: '2023:02:02 02:00:00',
SubSecCreateDate: '2023:03:03 03:00:00',
CreateDate: '2023:04:04 04:00:00',
CreationDate: '2023:05:05 05:00:00',
DateTimeCreated: '2023:06:06 06:00:00',
TimeCreated: '2023:07:07 07:00:00',
GPSDateTime: '2023:08:08 08:00:00',
DateTimeUTC: '2023:09:09 09:00:00',
GPSDateStamp: '2023:10:10',
SonyDateTime2: '2023:11:11 11:00:00',
},
},
{
filename: 'new-tags-only.jpg',
description: 'Image with only additional date tags (no standard tags)',
exifTags: {
TimeCreated: '2023:12:01 15:45:30',
GPSDateTime: '2023:12:01 13:45:30Z',
DateTimeUTC: '2023:12:01 13:45:30',
GPSDateStamp: '2023:12:01',
SonyDateTime2: '2023:12:01 08:45:30',
GPSLatitude: '40.7128',
GPSLongitude: '-74.0060',
GPSLatitudeRef: 'N',
GPSLongitudeRef: 'W',
},
},
];
const generateTestImages = async (): Promise<void> => {
// Target directory: e2e/test-assets/metadata/dates/
// Current file is in: e2e/src/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const targetDir = join(__dirname, '..', 'test-assets', 'metadata', 'dates');
console.log('Generating test images with additional EXIF date tags...');
console.log(`Target directory: ${targetDir}`);
for (const image of testImages) {
try {
const imagePath = join(targetDir, image.filename);
// Create unique JPEG file using Sharp
const r = Math.floor(Math.random() * 256);
const g = Math.floor(Math.random() * 256);
const b = Math.floor(Math.random() * 256);
const jpegData = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r, g, b },
},
})
.jpeg({ quality: 90 })
.toBuffer();
writeFileSync(imagePath, jpegData);
// Build exiftool command to add EXIF data
const exifArgs = Object.entries(image.exifTags)
.map(([tag, value]) => `-${tag}="${value}"`)
.join(' ');
const command = `exiftool ${exifArgs} -overwrite_original "${imagePath}"`;
console.log(`Creating ${image.filename}: ${image.description}`);
execSync(command, { stdio: 'pipe' });
// Verify the tags were written
const verifyCommand = `exiftool -json "${imagePath}"`;
const result = execSync(verifyCommand, { encoding: 'utf8' });
const metadata = JSON.parse(result)[0];
console.log(` ✓ Created with ${Object.keys(image.exifTags).length} EXIF tags`);
// Log first date tag found for verification
const firstDateTag = Object.keys(image.exifTags).find(
(tag) => tag.includes('Date') || tag.includes('Time') || tag.includes('Created'),
);
if (firstDateTag && metadata[firstDateTag]) {
console.log(` ✓ Verified ${firstDateTag}: ${metadata[firstDateTag]}`);
}
} catch (error) {
console.error(`Failed to create ${image.filename}:`, (error as Error).message);
}
}
console.log('\nTest image generation complete!');
console.log('Files created in:', targetDir);
console.log('\nTo test these images:');
console.log(`cd ${targetDir} && exiftool -time:all -gps:all *.jpg`);
};
export { generateTestImages };
// Run the generator if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
generateTestImages().catch(console.error);
}

View File

@@ -116,7 +116,6 @@ export const deviceDto = {
createdAt: expect.any(String),
updatedAt: expect.any(String),
current: true,
isPendingSyncReset: false,
deviceOS: '',
deviceType: '',
},

View File

@@ -12,7 +12,6 @@ export enum OAuthUser {
NO_NAME = 'no-name',
WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role',
}
const claims = [
@@ -35,12 +34,6 @@ const claims = [
preferred_username: 'user-quota',
immich_quota: 25,
},
{
sub: OAuthUser.WITH_ROLE,
email: 'oauth-with-role@immich.app',
email_verified: true,
immich_role: 'admin',
},
];
const withDefaultClaims = (sub: string) => ({
@@ -71,15 +64,7 @@ const setup = async () => {
claims: {
openid: ['sub'],
email: ['email', 'email_verified'],
profile: [
'name',
'given_name',
'family_name',
'preferred_username',
'immich_quota',
'immich_username',
'immich_role',
],
profile: ['name', 'given_name', 'family_name', 'preferred_username', 'immich_quota', 'immich_username'],
},
features: {
jwtUserinfo: {

View File

@@ -60,7 +60,6 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
export type { Emitter } from '@socket.io/component-emitter';
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete' | 'assetHidden';
@@ -85,10 +84,10 @@ export const immichAdmin = (args: string[]) =>
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const executeCommand = (command: string, args: string[], options?: { cwd?: string }) => {
const executeCommand = (command: string, args: string[]) => {
let _resolve: (value: CommandResponse) => void;
const promise = new Promise<CommandResponse>((resolve) => (_resolve = resolve));
const child = spawn(command, args, { stdio: 'pipe', cwd: options?.cwd });
const child = spawn(command, args, { stdio: 'pipe' });
let stdout = '';
let stderr = '';
@@ -154,19 +153,19 @@ export const utils = {
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
'shared_link',
'asset_stack',
'libraries',
'shared_links',
'person',
'album',
'asset',
'asset_face',
'albums',
'assets',
'asset_faces',
'activity',
'api_key',
'session',
'user',
'api_keys',
'sessions',
'users',
'system_metadata',
'tag',
'tags',
];
const sql: string[] = [];
@@ -175,7 +174,7 @@ export const utils = {
if (table === 'system_metadata') {
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
} else {
sql.push(`DELETE FROM "${table}" CASCADE;`);
sql.push(`DELETE FROM ${table} CASCADE;`);
}
}
@@ -451,7 +450,7 @@ export const utils = {
return;
}
await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
await client.query('INSERT INTO asset_faces ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]);
},
setPersonThumbnail: async (personId: string) => {

View File

@@ -166,20 +166,6 @@
"metadata_settings_description": "Manage metadata settings",
"migration_job": "Migration",
"migration_job_description": "Migrate thumbnails for assets and faces to the latest folder structure",
"nightly_tasks_cluster_faces_setting_description": "Run facial recognition on newly detected faces",
"nightly_tasks_cluster_new_faces_setting": "Cluster new faces",
"nightly_tasks_database_cleanup_setting": "Database cleanup tasks",
"nightly_tasks_database_cleanup_setting_description": "Clean up old, expired data from the database",
"nightly_tasks_generate_memories_setting": "Generate memories",
"nightly_tasks_generate_memories_setting_description": "Create new memories from assets",
"nightly_tasks_missing_thumbnails_setting": "Generate missing thumbnails",
"nightly_tasks_missing_thumbnails_setting_description": "Queue assets without thumbnails for thumbnail generation",
"nightly_tasks_settings": "Nightly Tasks Settings",
"nightly_tasks_settings_description": "Manage nightly tasks",
"nightly_tasks_start_time_setting": "Start time",
"nightly_tasks_start_time_setting_description": "The time at which the server starts running the nightly tasks",
"nightly_tasks_sync_quota_usage_setting": "Sync quota usage",
"nightly_tasks_sync_quota_usage_setting_description": "Update user storage quota, based on current usage",
"no_paths_added": "No paths added",
"no_pattern_added": "No pattern added",
"note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
@@ -210,8 +196,6 @@
"oauth_mobile_redirect_uri": "Mobile redirect URI",
"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_role_claim": "Role Claim",
"oauth_role_claim_description": "Automatically grant admin access based on the presence of this claim. The claim may have either 'user' or 'admin'.",
"oauth_settings": "OAuth",
"oauth_settings_description": "Manage OAuth login settings",
"oauth_settings_more_details": "For more details about this feature, refer to the <link>docs</link>.",
@@ -443,7 +427,6 @@
"app_settings": "App Settings",
"appears_in": "Appears in",
"archive": "Archive",
"archive_action_prompt": "{count} added to Archive",
"archive_or_unarchive_photo": "Archive or unarchive photo",
"archive_page_no_archived_assets": "No archived assets found",
"archive_page_title": "Archive ({count})",
@@ -481,6 +464,7 @@
"assets": "Assets",
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
"assets_added_to_name_count": "Added {count, plural, one {# asset} other {# assets}} to {hasName, select, true {<b>{name}</b>} other {new album}}",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
@@ -719,7 +703,7 @@
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
"dark_theme": "Toggle dark theme",
"darkTheme": "Toggle dark theme",
"date_after": "Date after",
"date_and_time": "Date and Time",
"date_before": "Date before",
@@ -735,7 +719,6 @@
"default_locale": "Default Locale",
"default_locale_description": "Format dates and numbers based on your browser locale",
"delete": "Delete",
"delete_action_prompt": "{count} deleted permanently",
"delete_album": "Delete album",
"delete_api_key_prompt": "Are you sure you want to delete this API key?",
"delete_dialog_alert": "These items will be permanently deleted from Immich and from your device",
@@ -749,7 +732,6 @@
"delete_key": "Delete key",
"delete_library": "Delete Library",
"delete_link": "Delete link",
"delete_local_action_prompt": "{count} deleted locally",
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only",
"delete_local_dialog_ok_force": "Delete Anyway",
"delete_others": "Delete others",
@@ -817,7 +799,6 @@
"edit_key": "Edit key",
"edit_link": "Edit link",
"edit_location": "Edit location",
"edit_location_action_prompt": "{count} location edited",
"edit_location_dialog_title": "Location",
"edit_name": "Edit name",
"edit_people": "Edit people",
@@ -1003,7 +984,6 @@
"failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder",
"favorite": "Favorite",
"favorite_action_prompt": "{count} added to Favorites",
"favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
"favorites": "Favorites",
"favorites_page_no_favorites": "No favorite assets found",
@@ -1147,7 +1127,6 @@
"library_page_sort_created": "Created date",
"library_page_sort_last_modified": "Last modified",
"library_page_sort_title": "Album title",
"licenses": "Licenses",
"light": "Light",
"like_deleted": "Like deleted",
"link_motion_video": "Link motion video",
@@ -1267,7 +1246,6 @@
"more": "More",
"move": "Move",
"move_off_locked_folder": "Move out of locked folder",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
@@ -1517,9 +1495,7 @@
"remove_custom_date_range": "Remove custom date range",
"remove_deleted_assets": "Remove Deleted Assets",
"remove_from_album": "Remove from album",
"remove_from_album_action_prompt": "{count} removed from the album",
"remove_from_favorites": "Remove from favorites",
"remove_from_lock_folder_action_prompt": "{count} removed from the locked folder",
"remove_from_locked_folder": "Remove from locked folder",
"remove_from_locked_folder_confirmation": "Are you sure you want to move these photos and videos out of the locked folder? They will be visible in your library.",
"remove_from_shared_link": "Remove from shared link",
@@ -1862,7 +1838,6 @@
"total": "Total",
"total_usage": "Total usage",
"trash": "Trash",
"trash_action_prompt": "{count} moved to trash",
"trash_all": "Trash All",
"trash_count": "Trash {count, number}",
"trash_delete_asset": "Trash/Delete Asset",
@@ -1880,11 +1855,9 @@
"unable_to_change_pin_code": "Unable to change PIN code",
"unable_to_setup_pin_code": "Unable to setup PIN code",
"unarchive": "Unarchive",
"unarchive_action_prompt": "{count} removed from Archive",
"unarchived_count": "{count, plural, other {Unarchived #}}",
"undo": "Undo",
"unfavorite": "Unfavorite",
"unfavorite_action_prompt": "{count} removed from Favorites",
"unhide_person": "Unhide person",
"unknown": "Unknown",
"unknown_country": "Unknown Country",
@@ -1903,7 +1876,6 @@
"unselect_all_in": "Unselect all in {group}",
"unstack": "Un-stack",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"untagged": "Untagged",
"up_next": "Up next",
"updated_at": "Updated",
"updated_password": "Updated password",
@@ -1940,7 +1912,6 @@
"user_usage_stats_description": "View account usage statistics",
"username": "Username",
"users": "Users",
"users_added_to_album_count": "Added {count, plural, one {# user} other {# users}} to the album",
"utilities": "Utilities",
"validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL",

View File

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

View File

@@ -244,6 +244,7 @@ async def load(model: InferenceModel) -> InferenceModel:
async def idle_shutdown_task() -> None:
while True:
log.debug("Checking for inactivity...")
if (
last_called is not None
and not active_requests

View File

@@ -4,12 +4,9 @@ import sys
import requests
port = os.getenv("IMMICH_PORT", 3003)
host = os.getenv("IMMICH_HOST", "0.0.0.0")
host = "localhost" if host == "0.0.0.0" else host
try:
response = requests.get(f"http://{host}:{port}/ping", timeout=2)
response = requests.get(f"http://localhost:{port}/ping", timeout=2)
if response.status_code == 200:
sys.exit(0)
sys.exit(1)

189
machine-learning/uv.lock generated
View File

@@ -517,16 +517,16 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.115.14"
version = "0.115.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" },
{ url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" },
]
[[package]]
@@ -900,7 +900,7 @@ wheels = [
[[package]]
name = "huggingface-hub"
version = "0.33.2"
version = "0.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
@@ -912,9 +912,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/8a95c5632080ae312c0498744b2b852195e10b05a20b1be11c5141092f4c/huggingface_hub-0.33.2.tar.gz", hash = "sha256:84221defaec8fa09c090390cd68c78b88e3c4c2b7befba68d3dc5aacbc3c2c5f", size = 426637, upload-time = "2025-07-02T06:26:05.156Z" }
sdist = { url = "https://files.pythonhosted.org/packages/60/c8/4f7d270285c46324fd66f62159eb16739aa5696f422dba57678a8c6b78e9/huggingface_hub-0.32.4.tar.gz", hash = "sha256:f61d45cd338736f59fb0e97550b74c24ee771bcc92c05ae0766b9116abe720be", size = 424494, upload-time = "2025-06-03T09:59:46.105Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/f4/5f3f22e762ad1965f01122b42dae5bf0e009286e2dba601ce1d0dba72424/huggingface_hub-0.33.2-py3-none-any.whl", hash = "sha256:3749498bfa91e8cde2ddc2c1db92c79981f40e66434c20133b39e5928ac9bcc5", size = 515373, upload-time = "2025-07-02T06:26:03.072Z" },
{ url = "https://files.pythonhosted.org/packages/67/8b/222140f3cfb6f17b0dd8c4b9a0b36bd4ebefe9fb0098ba35d6960abcda0f/huggingface_hub-0.32.4-py3-none-any.whl", hash = "sha256:37abf8826b38d971f60d3625229221c36e53fe58060286db9baf619cfbf39767", size = 512101, upload-time = "2025-06-03T09:59:44.099Z" },
]
[[package]]
@@ -1044,7 +1044,7 @@ requires-dist = [
{ name = "onnxruntime", marker = "extra == 'armnn'", specifier = ">=1.15.0,<2" },
{ name = "onnxruntime", marker = "extra == 'cpu'", specifier = ">=1.15.0,<2" },
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.15.0,<2" },
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.17.0,<2", index = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple" },
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.17.0,<2", index = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/" },
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.17.1,<1.19.0" },
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
{ name = "orjson", specifier = ">=3.9.5" },
@@ -1225,7 +1225,7 @@ wheels = [
[[package]]
name = "locust"
version = "2.37.11"
version = "2.37.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "configargparse" },
@@ -1245,14 +1245,14 @@ dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4f/19/66cdab585f7d4385be615d3792402fc75a1bed7519e5283adbe7133dbc78/locust-2.37.11.tar.gz", hash = "sha256:89c79bc599aa57160bd41dd3876e35d8b9dee5abded78e35008d01fd8f1640ed", size = 2252602, upload-time = "2025-06-23T08:22:23.922Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/05/2bfdf19756c6a12f6f9513f75340ecf0595d83cab4d9fc91162225908e3d/locust-2.37.9.tar.gz", hash = "sha256:e43673b594ec5ecde4f9ba6e0d5c66c00d7c0ae93591951abe83e8d186c67175", size = 2252507, upload-time = "2025-06-05T09:26:58.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2d/e5ae05911521bf84113be349d51b16d54589e986837d2d518f63434ea3ec/locust-2.37.11-py3-none-any.whl", hash = "sha256:b826f95fbfd5d9a32df6ab1b74672b88e65bbc33ec99fdc10af98079952ad517", size = 2269179, upload-time = "2025-06-23T08:22:21.067Z" },
{ url = "https://files.pythonhosted.org/packages/33/1c/0ece4176231c578e819d970ec08d124492833e50aafd171c582bcc414446/locust-2.37.9-py3-none-any.whl", hash = "sha256:e17da439f3a252d1fb6d4c34daf00d7e8b87e99d833a32e8a79f4f8ebb07767d", size = 2269084, upload-time = "2025-06-05T09:26:56.257Z" },
]
[[package]]
name = "locust-cloud"
version = "1.24.2"
version = "1.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "configargparse" },
@@ -1262,9 +1262,9 @@ dependencies = [
{ name = "python-socketio", extra = ["client"] },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/77/bda24167a2b763ba5d3cad1f3fa2a938f5273e51a61bffdbc8dc2e3ba24d/locust_cloud-1.24.2.tar.gz", hash = "sha256:a2656537ff367e6d4d4673477ba9e81ed73a8423a71573cd2512248740eded77", size = 451122, upload-time = "2025-06-23T11:08:00.558Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/7c/d9cbbd051490aeedfbd6ddda8ad48f77dd848ee490f6ebd166d20db5911e/locust_cloud-1.23.1.tar.gz", hash = "sha256:a09161752b8c9a9205e97cef5223ee3ad967bc2d91c52d61952aaa3da6802a55", size = 450937, upload-time = "2025-06-05T06:07:53.773Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/38/8cda8aa1c6dfe5c5abbf69a9b10c03585c37eff64ca92733a291806052ac/locust_cloud-1.24.2-py3-none-any.whl", hash = "sha256:64a5e6f2bf0a1a012d9805291d44fb57e57535c2b5c0fa5bc87ba0d7cce9ef9c", size = 408594, upload-time = "2025-06-23T11:07:59.092Z" },
{ url = "https://files.pythonhosted.org/packages/7e/01/5af43edee540e38ba0ee0a2e3beb72c50073e0f646bb543a8b34650315e3/locust_cloud-1.23.1-py3-none-any.whl", hash = "sha256:11677895c6ed6d0beef1b425a6f04f10ea2cfcaeaefbf00a97fb3c9134296e54", size = 408323, upload-time = "2025-06-05T06:07:51.947Z" },
]
[[package]]
@@ -1415,7 +1415,7 @@ wheels = [
[[package]]
name = "mypy"
version = "1.16.1"
version = "1.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
@@ -1423,33 +1423,33 @@ dependencies = [
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" },
{ url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" },
{ url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" },
{ url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" },
{ url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" },
{ url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" },
{ url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" },
{ url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" },
{ url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" },
{ url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" },
{ url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" },
{ url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" },
{ url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" },
{ url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" },
{ url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" },
{ url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" },
{ url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" },
{ url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" },
{ url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" },
{ url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" },
{ url = "https://files.pythonhosted.org/packages/64/5e/a0485f0608a3d67029d3d73cec209278b025e3493a3acfda3ef3a88540fd/mypy-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7909541fef256527e5ee9c0a7e2aeed78b6cda72ba44298d1334fe7881b05c5c", size = 10967416, upload-time = "2025-05-29T13:34:17.783Z" },
{ url = "https://files.pythonhosted.org/packages/4b/53/5837c221f74c0d53a4bfc3003296f8179c3a2a7f336d7de7bbafbe96b688/mypy-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e71d6f0090c2256c713ed3d52711d01859c82608b5d68d4fa01a3fe30df95571", size = 10087654, upload-time = "2025-05-29T13:32:37.878Z" },
{ url = "https://files.pythonhosted.org/packages/29/59/5fd2400352c3093bed4c09017fe671d26bc5bb7e6ef2d4bf85f2a2488104/mypy-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:936ccfdd749af4766be824268bfe22d1db9eb2f34a3ea1d00ffbe5b5265f5491", size = 11875192, upload-time = "2025-05-29T13:34:54.281Z" },
{ url = "https://files.pythonhosted.org/packages/ad/3e/4bfec74663a64c2012f3e278dbc29ffe82b121bc551758590d1b6449ec0c/mypy-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4086883a73166631307fdd330c4a9080ce24913d4f4c5ec596c601b3a4bdd777", size = 12612939, upload-time = "2025-05-29T13:33:14.766Z" },
{ url = "https://files.pythonhosted.org/packages/88/1f/fecbe3dcba4bf2ca34c26ca016383a9676711907f8db4da8354925cbb08f/mypy-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:feec38097f71797da0231997e0de3a58108c51845399669ebc532c815f93866b", size = 12874719, upload-time = "2025-05-29T13:21:52.09Z" },
{ url = "https://files.pythonhosted.org/packages/f3/51/c2d280601cd816c43dfa512a759270d5a5ef638d7ac9bea9134c8305a12f/mypy-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:09a8da6a0ee9a9770b8ff61b39c0bb07971cda90e7297f4213741b48a0cc8d93", size = 9487053, upload-time = "2025-05-29T13:33:29.797Z" },
{ url = "https://files.pythonhosted.org/packages/24/c4/ff2f79db7075c274fe85b5fff8797d29c6b61b8854c39e3b7feb556aa377/mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab", size = 10884498, upload-time = "2025-05-29T13:18:54.066Z" },
{ url = "https://files.pythonhosted.org/packages/02/07/12198e83006235f10f6a7808917376b5d6240a2fd5dce740fe5d2ebf3247/mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2", size = 10011755, upload-time = "2025-05-29T13:34:00.851Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9b/5fd5801a72b5d6fb6ec0105ea1d0e01ab2d4971893076e558d4b6d6b5f80/mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff", size = 11800138, upload-time = "2025-05-29T13:32:55.082Z" },
{ url = "https://files.pythonhosted.org/packages/2e/81/a117441ea5dfc3746431e51d78a4aca569c677aa225bca2cc05a7c239b61/mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666", size = 12533156, upload-time = "2025-05-29T13:19:12.963Z" },
{ url = "https://files.pythonhosted.org/packages/3f/38/88ec57c6c86014d3f06251e00f397b5a7daa6888884d0abf187e4f5f587f/mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c", size = 12742426, upload-time = "2025-05-29T13:20:22.72Z" },
{ url = "https://files.pythonhosted.org/packages/bd/53/7e9d528433d56e6f6f77ccf24af6ce570986c2d98a5839e4c2009ef47283/mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b", size = 9478319, upload-time = "2025-05-29T13:21:17.582Z" },
{ url = "https://files.pythonhosted.org/packages/70/cf/158e5055e60ca2be23aec54a3010f89dcffd788732634b344fc9cb1e85a0/mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13", size = 11062927, upload-time = "2025-05-29T13:35:52.328Z" },
{ url = "https://files.pythonhosted.org/packages/94/34/cfff7a56be1609f5d10ef386342ce3494158e4d506516890142007e6472c/mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090", size = 10083082, upload-time = "2025-05-29T13:35:33.378Z" },
{ url = "https://files.pythonhosted.org/packages/b3/7f/7242062ec6288c33d8ad89574df87c3903d394870e5e6ba1699317a65075/mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1", size = 11828306, upload-time = "2025-05-29T13:21:02.164Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5f/b392f7b4f659f5b619ce5994c5c43caab3d80df2296ae54fa888b3d17f5a/mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8", size = 12702764, upload-time = "2025-05-29T13:20:42.826Z" },
{ url = "https://files.pythonhosted.org/packages/9b/c0/7646ef3a00fa39ac9bc0938626d9ff29d19d733011be929cfea59d82d136/mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730", size = 12896233, upload-time = "2025-05-29T13:18:37.446Z" },
{ url = "https://files.pythonhosted.org/packages/6d/38/52f4b808b3fef7f0ef840ee8ff6ce5b5d77381e65425758d515cdd4f5bb5/mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec", size = 9565547, upload-time = "2025-05-29T13:20:02.836Z" },
{ url = "https://files.pythonhosted.org/packages/97/9c/ca03bdbefbaa03b264b9318a98950a9c683e06472226b55472f96ebbc53d/mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b", size = 11059753, upload-time = "2025-05-29T13:18:18.167Z" },
{ url = "https://files.pythonhosted.org/packages/36/92/79a969b8302cfe316027c88f7dc6fee70129490a370b3f6eb11d777749d0/mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0", size = 10073338, upload-time = "2025-05-29T13:19:48.079Z" },
{ url = "https://files.pythonhosted.org/packages/14/9b/a943f09319167da0552d5cd722104096a9c99270719b1afeea60d11610aa/mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b", size = 11827764, upload-time = "2025-05-29T13:46:04.47Z" },
{ url = "https://files.pythonhosted.org/packages/ec/64/ff75e71c65a0cb6ee737287c7913ea155845a556c64144c65b811afdb9c7/mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d", size = 12701356, upload-time = "2025-05-29T13:35:13.553Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ad/0e93c18987a1182c350f7a5fab70550852f9fabe30ecb63bfbe51b602074/mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52", size = 12900745, upload-time = "2025-05-29T13:17:24.409Z" },
{ url = "https://files.pythonhosted.org/packages/28/5d/036c278d7a013e97e33f08c047fe5583ab4f1fc47c9a49f985f1cdd2a2d7/mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb", size = 9572200, upload-time = "2025-05-29T13:33:44.92Z" },
{ url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" },
]
[[package]]
@@ -1568,7 +1568,7 @@ wheels = [
[[package]]
name = "onnxruntime-gpu"
version = "1.19.2"
source = { registry = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple" }
source = { registry = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/" }
dependencies = [
{ name = "coloredlogs" },
{ name = "flatbuffers" },
@@ -1834,7 +1834,7 @@ wheels = [
[[package]]
name = "pydantic"
version = "2.11.7"
version = "2.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -1842,9 +1842,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
{ url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" },
]
[[package]]
@@ -1936,16 +1936,16 @@ wheels = [
[[package]]
name = "pydantic-settings"
version = "2.10.1"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" }
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" },
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" },
]
[[package]]
@@ -1977,7 +1977,7 @@ wheels = [
[[package]]
name = "pytest"
version = "8.4.1"
version = "8.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1988,9 +1988,9 @@ dependencies = [
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
{ url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" },
]
[[package]]
@@ -2007,16 +2007,15 @@ wheels = [
[[package]]
name = "pytest-cov"
version = "6.2.1"
version = "6.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" }
sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857, upload-time = "2025-04-05T14:07:51.592Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" },
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
]
[[package]]
@@ -2304,27 +2303,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.12.2"
version = "0.11.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/3d/d9a195676f25d00dbfcf3cf95fdd4c685c497fcfa7e862a44ac5e4e96480/ruff-0.12.2.tar.gz", hash = "sha256:d7b4f55cd6f325cb7621244f19c873c565a08aff5a4ba9c69aa7355f3f7afd3e", size = 4432239, upload-time = "2025-07-03T16:40:19.566Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/b6/2098d0126d2d3318fd5bec3ad40d06c25d377d95749f7a0c5af17129b3b1/ruff-0.12.2-py3-none-linux_armv6l.whl", hash = "sha256:093ea2b221df1d2b8e7ad92fc6ffdca40a2cb10d8564477a987b44fd4008a7be", size = 10369761, upload-time = "2025-07-03T16:39:38.847Z" },
{ url = "https://files.pythonhosted.org/packages/b1/4b/5da0142033dbe155dc598cfb99262d8ee2449d76920ea92c4eeb9547c208/ruff-0.12.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:09e4cf27cc10f96b1708100fa851e0daf21767e9709e1649175355280e0d950e", size = 11155659, upload-time = "2025-07-03T16:39:42.294Z" },
{ url = "https://files.pythonhosted.org/packages/3e/21/967b82550a503d7c5c5c127d11c935344b35e8c521f52915fc858fb3e473/ruff-0.12.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8ae64755b22f4ff85e9c52d1f82644abd0b6b6b6deedceb74bd71f35c24044cc", size = 10537769, upload-time = "2025-07-03T16:39:44.75Z" },
{ url = "https://files.pythonhosted.org/packages/33/91/00cff7102e2ec71a4890fb7ba1803f2cdb122d82787c7d7cf8041fe8cbc1/ruff-0.12.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eb3a6b2db4d6e2c77e682f0b988d4d61aff06860158fdb413118ca133d57922", size = 10717602, upload-time = "2025-07-03T16:39:47.652Z" },
{ url = "https://files.pythonhosted.org/packages/9b/eb/928814daec4e1ba9115858adcda44a637fb9010618721937491e4e2283b8/ruff-0.12.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:73448de992d05517170fc37169cbca857dfeaeaa8c2b9be494d7bcb0d36c8f4b", size = 10198772, upload-time = "2025-07-03T16:39:49.641Z" },
{ url = "https://files.pythonhosted.org/packages/50/fa/f15089bc20c40f4f72334f9145dde55ab2b680e51afb3b55422effbf2fb6/ruff-0.12.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b8b94317cbc2ae4a2771af641739f933934b03555e51515e6e021c64441532d", size = 11845173, upload-time = "2025-07-03T16:39:52.069Z" },
{ url = "https://files.pythonhosted.org/packages/43/9f/1f6f98f39f2b9302acc161a4a2187b1e3a97634fe918a8e731e591841cf4/ruff-0.12.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:45fc42c3bf1d30d2008023a0a9a0cfb06bf9835b147f11fe0679f21ae86d34b1", size = 12553002, upload-time = "2025-07-03T16:39:54.551Z" },
{ url = "https://files.pythonhosted.org/packages/d8/70/08991ac46e38ddd231c8f4fd05ef189b1b94be8883e8c0c146a025c20a19/ruff-0.12.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce48f675c394c37e958bf229fb5c1e843e20945a6d962cf3ea20b7a107dcd9f4", size = 12171330, upload-time = "2025-07-03T16:39:57.55Z" },
{ url = "https://files.pythonhosted.org/packages/88/a9/5a55266fec474acfd0a1c73285f19dd22461d95a538f29bba02edd07a5d9/ruff-0.12.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:793d8859445ea47591272021a81391350205a4af65a9392401f418a95dfb75c9", size = 11774717, upload-time = "2025-07-03T16:39:59.78Z" },
{ url = "https://files.pythonhosted.org/packages/87/e5/0c270e458fc73c46c0d0f7cf970bb14786e5fdb88c87b5e423a4bd65232b/ruff-0.12.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6932323db80484dda89153da3d8e58164d01d6da86857c79f1961934354992da", size = 11646659, upload-time = "2025-07-03T16:40:01.934Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b6/45ab96070c9752af37f0be364d849ed70e9ccede07675b0ec4e3ef76b63b/ruff-0.12.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6aa7e623a3a11538108f61e859ebf016c4f14a7e6e4eba1980190cacb57714ce", size = 10604012, upload-time = "2025-07-03T16:40:04.363Z" },
{ url = "https://files.pythonhosted.org/packages/86/91/26a6e6a424eb147cc7627eebae095cfa0b4b337a7c1c413c447c9ebb72fd/ruff-0.12.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2a4a20aeed74671b2def096bdf2eac610c7d8ffcbf4fb0e627c06947a1d7078d", size = 10176799, upload-time = "2025-07-03T16:40:06.514Z" },
{ url = "https://files.pythonhosted.org/packages/f5/0c/9f344583465a61c8918a7cda604226e77b2c548daf8ef7c2bfccf2b37200/ruff-0.12.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:71a4c550195612f486c9d1f2b045a600aeba851b298c667807ae933478fcef04", size = 11241507, upload-time = "2025-07-03T16:40:08.708Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b7/99c34ded8fb5f86c0280278fa89a0066c3760edc326e935ce0b1550d315d/ruff-0.12.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4987b8f4ceadf597c927beee65a5eaf994c6e2b631df963f86d8ad1bdea99342", size = 11717609, upload-time = "2025-07-03T16:40:10.836Z" },
{ url = "https://files.pythonhosted.org/packages/51/de/8589fa724590faa057e5a6d171e7f2f6cffe3287406ef40e49c682c07d89/ruff-0.12.2-py3-none-win32.whl", hash = "sha256:369ffb69b70cd55b6c3fc453b9492d98aed98062db9fec828cdfd069555f5f1a", size = 10523823, upload-time = "2025-07-03T16:40:13.203Z" },
{ url = "https://files.pythonhosted.org/packages/94/47/8abf129102ae4c90cba0c2199a1a9b0fa896f6f806238d6f8c14448cc748/ruff-0.12.2-py3-none-win_amd64.whl", hash = "sha256:dca8a3b6d6dc9810ed8f328d406516bf4d660c00caeaef36eb831cf4871b0639", size = 11629831, upload-time = "2025-07-03T16:40:15.478Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1f/72d2946e3cc7456bb837e88000eb3437e55f80db339c840c04015a11115d/ruff-0.12.2-py3-none-win_arm64.whl", hash = "sha256:48d6c6bfb4761df68bc05ae630e24f506755e702d4fb08f08460be778c7ccb12", size = 10735334, upload-time = "2025-07-03T16:40:17.677Z" },
{ url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" },
{ url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" },
{ url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" },
{ url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" },
{ url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" },
{ url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" },
{ url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" },
{ url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" },
{ url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" },
{ url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" },
{ url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" },
]
[[package]]
@@ -2504,27 +2503,27 @@ wheels = [
[[package]]
name = "tokenizers"
version = "0.21.2"
version = "0.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/2d/b0fce2b8201635f60e8c95990080f58461cc9ca3d5026de2e900f38a7f21/tokenizers-0.21.2.tar.gz", hash = "sha256:fdc7cffde3e2113ba0e6cc7318c40e3438a4d74bbc62bf04bcc63bdfb082ac77", size = 351545, upload-time = "2025-06-24T10:24:52.449Z" }
sdist = { url = "https://files.pythonhosted.org/packages/92/76/5ac0c97f1117b91b7eb7323dcd61af80d72f790b4df71249a7850c195f30/tokenizers-0.21.1.tar.gz", hash = "sha256:a1bb04dc5b448985f86ecd4b05407f5a8d97cb2c0532199b2a302a604a0165ab", size = 343256, upload-time = "2025-03-13T10:51:18.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/cc/2936e2d45ceb130a21d929743f1e9897514691bec123203e10837972296f/tokenizers-0.21.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:342b5dfb75009f2255ab8dec0041287260fed5ce00c323eb6bab639066fef8ec", size = 2875206, upload-time = "2025-06-24T10:24:42.755Z" },
{ url = "https://files.pythonhosted.org/packages/6c/e6/33f41f2cc7861faeba8988e7a77601407bf1d9d28fc79c5903f8f77df587/tokenizers-0.21.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:126df3205d6f3a93fea80c7a8a266a78c1bd8dd2fe043386bafdd7736a23e45f", size = 2732655, upload-time = "2025-06-24T10:24:41.56Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1791eb329c07122a75b01035b1a3aa22ad139f3ce0ece1b059b506d9d9de/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a32cd81be21168bd0d6a0f0962d60177c447a1aa1b1e48fa6ec9fc728ee0b12", size = 3019202, upload-time = "2025-06-24T10:24:31.791Z" },
{ url = "https://files.pythonhosted.org/packages/05/15/fd2d8104faa9f86ac68748e6f7ece0b5eb7983c7efc3a2c197cb98c99030/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8bd8999538c405133c2ab999b83b17c08b7fc1b48c1ada2469964605a709ef91", size = 2934539, upload-time = "2025-06-24T10:24:34.567Z" },
{ url = "https://files.pythonhosted.org/packages/a5/2e/53e8fd053e1f3ffbe579ca5f9546f35ac67cf0039ed357ad7ec57f5f5af0/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e9944e61239b083a41cf8fc42802f855e1dca0f499196df37a8ce219abac6eb", size = 3248665, upload-time = "2025-06-24T10:24:39.024Z" },
{ url = "https://files.pythonhosted.org/packages/00/15/79713359f4037aa8f4d1f06ffca35312ac83629da062670e8830917e2153/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:514cd43045c5d546f01142ff9c79a96ea69e4b5cda09e3027708cb2e6d5762ab", size = 3451305, upload-time = "2025-06-24T10:24:36.133Z" },
{ url = "https://files.pythonhosted.org/packages/38/5f/959f3a8756fc9396aeb704292777b84f02a5c6f25c3fc3ba7530db5feb2c/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1b9405822527ec1e0f7d8d2fdb287a5730c3a6518189c968254a8441b21faae", size = 3214757, upload-time = "2025-06-24T10:24:37.784Z" },
{ url = "https://files.pythonhosted.org/packages/c5/74/f41a432a0733f61f3d21b288de6dfa78f7acff309c6f0f323b2833e9189f/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed9a4d51c395103ad24f8e7eb976811c57fbec2af9f133df471afcd922e5020", size = 3121887, upload-time = "2025-06-24T10:24:40.293Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6a/bc220a11a17e5d07b0dfb3b5c628621d4dcc084bccd27cfaead659963016/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c41862df3d873665ec78b6be36fcc30a26e3d4902e9dd8608ed61d49a48bc19", size = 9091965, upload-time = "2025-06-24T10:24:44.431Z" },
{ url = "https://files.pythonhosted.org/packages/6c/bd/ac386d79c4ef20dc6f39c4706640c24823dca7ebb6f703bfe6b5f0292d88/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed21dc7e624e4220e21758b2e62893be7101453525e3d23264081c9ef9a6d00d", size = 9053372, upload-time = "2025-06-24T10:24:46.455Z" },
{ url = "https://files.pythonhosted.org/packages/63/7b/5440bf203b2a5358f074408f7f9c42884849cd9972879e10ee6b7a8c3b3d/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:0e73770507e65a0e0e2a1affd6b03c36e3bc4377bd10c9ccf51a82c77c0fe365", size = 9298632, upload-time = "2025-06-24T10:24:48.446Z" },
{ url = "https://files.pythonhosted.org/packages/a4/d2/faa1acac3f96a7427866e94ed4289949b2524f0c1878512516567d80563c/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:106746e8aa9014a12109e58d540ad5465b4c183768ea96c03cbc24c44d329958", size = 9470074, upload-time = "2025-06-24T10:24:50.378Z" },
{ url = "https://files.pythonhosted.org/packages/d8/a5/896e1ef0707212745ae9f37e84c7d50269411aef2e9ccd0de63623feecdf/tokenizers-0.21.2-cp39-abi3-win32.whl", hash = "sha256:cabda5a6d15d620b6dfe711e1af52205266d05b379ea85a8a301b3593c60e962", size = 2330115, upload-time = "2025-06-24T10:24:55.069Z" },
{ url = "https://files.pythonhosted.org/packages/13/c3/cc2755ee10be859c4338c962a35b9a663788c0c0b50c0bdd8078fb6870cf/tokenizers-0.21.2-cp39-abi3-win_amd64.whl", hash = "sha256:58747bb898acdb1007f37a7bbe614346e98dc28708ffb66a3fd50ce169ac6c98", size = 2509918, upload-time = "2025-06-24T10:24:53.71Z" },
{ url = "https://files.pythonhosted.org/packages/a5/1f/328aee25f9115bf04262e8b4e5a2050b7b7cf44b59c74e982db7270c7f30/tokenizers-0.21.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e78e413e9e668ad790a29456e677d9d3aa50a9ad311a40905d6861ba7692cf41", size = 2780767, upload-time = "2025-03-13T10:51:09.459Z" },
{ url = "https://files.pythonhosted.org/packages/ae/1a/4526797f3719b0287853f12c5ad563a9be09d446c44ac784cdd7c50f76ab/tokenizers-0.21.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cd51cd0a91ecc801633829fcd1fda9cf8682ed3477c6243b9a095539de4aecf3", size = 2650555, upload-time = "2025-03-13T10:51:07.692Z" },
{ url = "https://files.pythonhosted.org/packages/4d/7a/a209b29f971a9fdc1da86f917fe4524564924db50d13f0724feed37b2a4d/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28da6b72d4fb14ee200a1bd386ff74ade8992d7f725f2bde2c495a9a98cf4d9f", size = 2937541, upload-time = "2025-03-13T10:50:56.679Z" },
{ url = "https://files.pythonhosted.org/packages/3c/1e/b788b50ffc6191e0b1fc2b0d49df8cff16fe415302e5ceb89f619d12c5bc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34d8cfde551c9916cb92014e040806122295a6800914bab5865deb85623931cf", size = 2819058, upload-time = "2025-03-13T10:50:59.525Z" },
{ url = "https://files.pythonhosted.org/packages/36/aa/3626dfa09a0ecc5b57a8c58eeaeb7dd7ca9a37ad9dd681edab5acd55764c/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaa852d23e125b73d283c98f007e06d4595732104b65402f46e8ef24b588d9f8", size = 3133278, upload-time = "2025-03-13T10:51:04.678Z" },
{ url = "https://files.pythonhosted.org/packages/a4/4d/8fbc203838b3d26269f944a89459d94c858f5b3f9a9b6ee9728cdcf69161/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a21a15d5c8e603331b8a59548bbe113564136dc0f5ad8306dd5033459a226da0", size = 3144253, upload-time = "2025-03-13T10:51:01.261Z" },
{ url = "https://files.pythonhosted.org/packages/d8/1b/2bd062adeb7c7511b847b32e356024980c0ffcf35f28947792c2d8ad2288/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdbd4c067c60a0ac7eca14b6bd18a5bebace54eb757c706b47ea93204f7a37c", size = 3398225, upload-time = "2025-03-13T10:51:03.243Z" },
{ url = "https://files.pythonhosted.org/packages/8a/63/38be071b0c8e06840bc6046991636bcb30c27f6bb1e670f4f4bc87cf49cc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd9a0061e403546f7377df940e866c3e678d7d4e9643d0461ea442b4f89e61a", size = 3038874, upload-time = "2025-03-13T10:51:06.235Z" },
{ url = "https://files.pythonhosted.org/packages/ec/83/afa94193c09246417c23a3c75a8a0a96bf44ab5630a3015538d0c316dd4b/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db9484aeb2e200c43b915a1a0150ea885e35f357a5a8fabf7373af333dcc8dbf", size = 9014448, upload-time = "2025-03-13T10:51:10.927Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b3/0e1a37d4f84c0f014d43701c11eb8072704f6efe8d8fc2dcdb79c47d76de/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed248ab5279e601a30a4d67bdb897ecbe955a50f1e7bb62bd99f07dd11c2f5b6", size = 8937877, upload-time = "2025-03-13T10:51:12.688Z" },
{ url = "https://files.pythonhosted.org/packages/ac/33/ff08f50e6d615eb180a4a328c65907feb6ded0b8f990ec923969759dc379/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:9ac78b12e541d4ce67b4dfd970e44c060a2147b9b2a21f509566d556a509c67d", size = 9186645, upload-time = "2025-03-13T10:51:14.723Z" },
{ url = "https://files.pythonhosted.org/packages/5f/aa/8ae85f69a9f6012c6f8011c6f4aa1c96154c816e9eea2e1b758601157833/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5a69c1a4496b81a5ee5d2c1f3f7fbdf95e90a0196101b0ee89ed9956b8a168f", size = 9384380, upload-time = "2025-03-13T10:51:16.526Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5b/a5d98c89f747455e8b7a9504910c865d5e51da55e825a7ae641fb5ff0a58/tokenizers-0.21.1-cp39-abi3-win32.whl", hash = "sha256:1039a3a5734944e09de1d48761ade94e00d0fa760c0e0551151d4dd851ba63e3", size = 2239506, upload-time = "2025-03-13T10:51:20.643Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b6/072a8e053ae600dcc2ac0da81a23548e3b523301a442a6ca900e92ac35be/tokenizers-0.21.1-cp39-abi3-win_amd64.whl", hash = "sha256:0f0dcbcc9f6e13e675a66d7a5f2f225a736745ce484c1a4e07476a89ccdad382", size = 2435481, upload-time = "2025-03-13T10:51:19.243Z" },
]
[[package]]
@@ -2559,14 +2558,14 @@ wheels = [
[[package]]
name = "types-requests"
version = "2.32.4.20250611"
version = "2.32.0.20250602"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/7f/73b3a04a53b0fd2a911d4ec517940ecd6600630b559e4505cc7b68beb5a0/types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826", size = 23118, upload-time = "2025-06-11T03:11:41.272Z" }
sdist = { url = "https://files.pythonhosted.org/packages/48/b0/5321e6eeba5d59e4347fcf9bf06a5052f085c3aa0f4876230566d6a4dc97/types_requests-2.32.0.20250602.tar.gz", hash = "sha256:ee603aeefec42051195ae62ca7667cd909a2f8128fdf8aad9e8a5219ecfab3bf", size = 23042, upload-time = "2025-06-02T03:15:02.958Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ea/0be9258c5a4fa1ba2300111aa5a0767ee6d18eb3fd20e91616c12082284d/types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072", size = 20643, upload-time = "2025-06-11T03:11:40.186Z" },
{ url = "https://files.pythonhosted.org/packages/da/18/9b782980e575c6581d5c0c1c99f4c6f89a1d7173dad072ee96b2756c02e6/types_requests-2.32.0.20250602-py3-none-any.whl", hash = "sha256:f4f335f87779b47ce10b8b8597b409130299f6971ead27fead4fe7ba6ea3e726", size = 20638, upload-time = "2025-06-02T03:15:01.959Z" },
]
[[package]]
@@ -2628,16 +2627,16 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.35.0"
version = "0.34.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
{ url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" },
]
[package.optional-dependencies]

View File

@@ -1,3 +1,3 @@
{
"flutter": "3.32.6"
"flutter": "3.29.3"
}

View File

@@ -1,5 +1,5 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.32.6",
"dart.flutterSdkPath": ".fvm/versions/3.29.3",
"search.exclude": {
"**/.fvm": true
},

View File

@@ -146,7 +146,6 @@ dart_code_metrics:
# - no-empty-block
# - no-equal-then-else
# - prefer-correct-test-file-name
- prefer-const-border-radius
# - prefer-match-file-name
# - prefer-return-await
# - avoid-self-assignment
@@ -291,8 +290,7 @@ dart_code_metrics:
# Style
# - prefer-trailing-comma
# - unnecessary-trailing-comma
- prefer-declaring-const-constructor
# - prefer-declaring-const-constructor
# - prefer-single-widget-per-file
- prefer-switch-expression
# - prefer-prefixed-global-constants
# - prefer-correct-callback-field-name

View File

@@ -66,20 +66,6 @@ android {
}
}
flavorDimensions "default"
productFlavors {
production {
dimension "default"
applicationId "app.alextran.immich"
}
beta {
dimension "default"
applicationId "app.alextran.immich.beta"
versionNameSuffix "-BETA"
}
}
buildTypes {
debug {
applicationIdSuffix '.debug'

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application android:label="Immich Beta" tools:replace="android:label" />
</manifest>

View File

@@ -90,35 +90,6 @@
<data android:mimeType="video/*" />
</intent-filter>
<!-- immich:// URL scheme handling -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="immich" />
</intent-filter>
<!-- my.immich.app deep link -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data
android:host="my.immich.app"
android:path="/" />
<data
android:host="my.immich.app"
android:pathPrefix="/albums/" />
<data
android:host="my.immich.app"
android:pathPrefix="/memories/" />
<data
android:host="my.immich.app"
android:pathPrefix="/photos/" />
</intent-filter>
</activity>

View File

@@ -87,8 +87,7 @@ data class PlatformAsset (
val updatedAt: Long? = null,
val width: Long? = null,
val height: Long? = null,
val durationInSeconds: Long,
val orientation: Long
val durationInSeconds: Long
)
{
companion object {
@@ -101,8 +100,7 @@ data class PlatformAsset (
val width = pigeonVar_list[5] as Long?
val height = pigeonVar_list[6] as Long?
val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation)
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds)
}
}
fun toList(): List<Any?> {
@@ -115,7 +113,6 @@ data class PlatformAsset (
width,
height,
durationInSeconds,
orientation,
)
}
override fun equals(other: Any?): Boolean {

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
import android.util.Log
import androidx.core.database.getStringOrNull
import java.io.File
import java.io.FileInputStream
import java.security.MessageDigest
@@ -40,8 +39,7 @@ open class NativeSyncApiImplBase(context: Context) {
MediaStore.MediaColumns.BUCKET_ID,
MediaStore.MediaColumns.WIDTH,
MediaStore.MediaColumns.HEIGHT,
MediaStore.MediaColumns.DURATION,
MediaStore.MediaColumns.ORIENTATION,
MediaStore.MediaColumns.DURATION
)
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
@@ -75,8 +73,6 @@ open class NativeSyncApiImplBase(context: Context) {
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
while (c.moveToNext()) {
val id = c.getLong(idColumn).toString()
@@ -104,7 +100,6 @@ open class NativeSyncApiImplBase(context: Context) {
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
else c.getLong(durationColumn) / 1000
val bucketId = c.getString(bucketIdColumn)
val orientation = c.getInt(orientationColumn)
val asset = PlatformAsset(
id,
@@ -114,8 +109,7 @@ open class NativeSyncApiImplBase(context: Context) {
modifiedAt,
width,
height,
duration,
orientation.toLong(),
duration
)
yield(AssetResult.ValidAsset(asset, bucketId))
}
@@ -158,8 +152,7 @@ open class NativeSyncApiImplBase(context: Context) {
continue
}
// MediaStore might return null for bucket name (commonly for the Root Directory), so default to "Internal Storage"
val name = cursor.getStringOrNull(bucketNameColumn) ?: "Internal Storage"
val name = cursor.getString(bucketNameColumn)
val updatedAt = cursor.getLong(dateModified)
albums.add(PlatformAlbum(id, name, updatedAt, false, 0))
albumsCount[id] = 1

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 204,
"android.injected.version.name" => "1.135.3",
"android.injected.version.code" => 201,
"android.injected.version.name" => "1.135.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -2,6 +2,4 @@ org.gradle.jvmargs=-Xmx4096M
android.useAndroidX=true
android.enableJetifier=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false
org.gradle.caching=true
org.gradle.parallel=true
android.nonFinalResIds=false

File diff suppressed because one or more lines are too long

View File

@@ -5,34 +5,34 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f
sha256: dc27559385e905ad30838356c5f5d574014ba39872d732111cd07ac0beff4c57
url: "https://pub.dev"
source: hosted
version: "82.0.0"
version: "80.0.0"
analyzer:
dependency: "direct main"
description:
name: analyzer
sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0"
sha256: "192d1c5b944e7e53b24b5586db760db934b177d4147c42fbca8c8c5f1eb8d11e"
url: "https://pub.dev"
source: hosted
version: "7.4.5"
version: "7.3.0"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
sha256: ee188b6df6c85f1441497c7171c84f1392affadc0384f71089cb10a3bc508cef
sha256: b3075265c5ab222f8b3188342dcb50b476286394a40323e85d1fa725035d40a4
url: "https://pub.dev"
source: hosted
version: "0.13.1"
version: "0.13.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
url: "https://pub.dev"
source: hosted
version: "2.7.0"
version: "2.6.0"
async:
dependency: transitive
description:
@@ -53,10 +53,10 @@ packages:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.4"
version: "2.0.3"
ci:
dependency: transitive
description:
@@ -125,10 +125,10 @@ packages:
dependency: transitive
description:
name: custom_lint_visitor
sha256: cba5b6d7a6217312472bf4468cdf68c949488aed7ffb0eab792cd0b6c435054d
sha256: "36282d85714af494ee2d7da8c8913630aa6694da99f104fb2ed4afcf8fc857d8"
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.4.5"
version: "1.0.0+7.3.0"
dart_style:
dependency: transitive
description:
@@ -157,10 +157,10 @@ packages:
dependency: transitive
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
sha256: c87ff004c8aa6af2d531668b46a4ea379f7191dc6dfa066acd53d506da6e044b
url: "https://pub.dev"
source: hosted
version: "3.1.0"
version: "3.0.0"
glob:
dependency: "direct main"
description:
@@ -213,18 +213,18 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.1.1"
path:
dependency: transitive
description:
@@ -317,10 +317,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.4"
typed_data:
dependency: transitive
description:
@@ -341,18 +341,18 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.2"
version: "15.0.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a"
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "1.1.1"
yaml:
dependency: transitive
description:
@@ -362,4 +362,4 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
dart: ">=3.8.0-0 <4.0.0"

View File

@@ -7,7 +7,7 @@ import 'general_helper.dart';
class ImmichTestLoginHelper {
final WidgetTester tester;
const ImmichTestLoginHelper(this.tester);
ImmichTestLoginHelper(this.tester);
Future<void> waitForLoginScreen() async {
await pumpUntilFound(tester, find.text("Login"));
@@ -60,11 +60,11 @@ class ImmichTestLoginHelper {
await tester.tap(button);
}
Future<void> assertLoginSuccess() async {
Future<void> assertLoginSuccess({int timeoutSeconds = 15}) async {
await pumpUntilFound(tester, find.text("home_page_building_timeline".tr()));
}
Future<void> assertLoginFailed() async {
Future<void> assertLoginFailed({int timeoutSeconds = 15}) async {
await pumpUntilFound(tester, find.text("login_form_failed_login".tr()));
}
}

View File

@@ -649,7 +649,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -793,7 +793,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -823,7 +823,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -857,7 +857,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -865,7 +865,7 @@
INFOPLIST_FILE = WidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -900,7 +900,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -908,7 +908,7 @@
INFOPLIST_FILE = WidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -940,7 +940,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -948,7 +948,7 @@
INFOPLIST_FILE = WidgetExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Widget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -979,7 +979,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1023,7 +1023,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1064,7 +1064,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 210;
CURRENT_PROJECT_VERSION = 209;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -26,7 +26,6 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
@@ -44,7 +43,6 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
disableMainThreadChecker = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.135.1</string>
<string>1.135.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -86,26 +86,14 @@
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Share Extension</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>Deep Link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>immich</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>210</string>
<string>209</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
@@ -127,13 +115,11 @@
</dict>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
@@ -180,8 +166,7 @@
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<key>NSLocalNetworkUsageDescription</key>
<string>We need local network permission to connect to the local server using IP address and
allow the casting feature to work</string>
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
</dict>
</plist>
</plist>

View File

@@ -2,10 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>

View File

@@ -4,10 +4,6 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:my.immich.app</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>

View File

@@ -138,7 +138,6 @@ struct PlatformAsset: Hashable {
var width: Int64? = nil
var height: Int64? = nil
var durationInSeconds: Int64
var orientation: Int64
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -151,7 +150,6 @@ struct PlatformAsset: Hashable {
let width: Int64? = nilOrValue(pigeonVar_list[5])
let height: Int64? = nilOrValue(pigeonVar_list[6])
let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64
return PlatformAsset(
id: id,
@@ -161,8 +159,7 @@ struct PlatformAsset: Hashable {
updatedAt: updatedAt,
width: width,
height: height,
durationInSeconds: durationInSeconds,
orientation: orientation
durationInSeconds: durationInSeconds
)
}
func toList() -> [Any?] {
@@ -175,7 +172,6 @@ struct PlatformAsset: Hashable {
width,
height,
durationInSeconds,
orientation,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {

View File

@@ -27,8 +27,7 @@ extension PHAsset {
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
width: Int64(pixelWidth),
height: Int64(pixelHeight),
durationInSeconds: Int64(duration),
orientation: 0
durationInSeconds: Int64(duration)
)
}
}
@@ -170,8 +169,7 @@ class NativeSyncApiImpl: NativeSyncApi {
id: asset.localIdentifier,
name: "",
type: 0,
durationInSeconds: 0,
orientation: 0
durationInSeconds: 0
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue

View File

@@ -0,0 +1,58 @@
import SwiftUI
import WidgetKit
func buildEntry(
api: ImmichAPI,
asset: SearchResult,
dateOffset: Int,
subtitle: String? = nil
)
async throws -> ImageEntry
{
let entryDate = Calendar.current.date(
byAdding: .minute,
value: dateOffset * 20,
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return ImageEntry(date: entryDate, image: image, subtitle: subtitle)
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
albumId: String? = nil,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let albumIds = albumId != nil ? [albumId!] : []
let randomAssets = try await api.fetchSearchResults(
with: SearchFilters(size: count, albumIds: albumIds)
)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
group.addTask {
return try? await buildEntry(
api: api,
asset: asset,
dateOffset: dateOffset,
subtitle: subtitle
)
}
}
for await result in group {
if let entry = result {
entries.append(entry)
}
}
}
return entries
}

View File

@@ -1,148 +0,0 @@
import SwiftUI
import WidgetKit
typealias EntryMetadata = ImageEntry.Metadata
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var metadata: Metadata = Metadata()
struct Metadata: Codable {
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
}
static func build(
api: ImmichAPI,
asset: Asset,
dateOffset: Int,
subtitle: String? = nil
)
async throws -> Self
{
let entryDate = Calendar.current.date(
byAdding: .minute,
value: dateOffset * 20,
to: Date.now
)!
let image = try await api.fetchImage(asset: asset)
return Self(
date: entryDate,
image: image,
metadata: EntryMetadata(
subtitle: subtitle,
deepLink: asset.deepLink
)
)
}
func cache(for key: String) throws {
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
// build metadata JSON
let entryMetadata = try JSONEncoder().encode(self.metadata)
// write to disk
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
try entryMetadata.write(to: metadataURL, options: .atomic)
}
}
static func loadCached(for key: String, at date: Date = Date.now)
-> ImageEntry?
{
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
return ImageEntry(
date: date,
image: UIImage(data: imageData),
metadata: decodedMetadata
)
}
return nil
}
static func handleError(
for key: String,
error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry(
date: Date.now,
image: nil,
metadata: EntryMetadata(error: error)
)
// use cache if generic failed error
// we want to show the other errors to the user since without intervention,
// it will never succeed
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
{
timelineEntry = cachedEntry
}
return Timeline(entries: [timelineEntry], policy: .atEnd)
}
}
func generateRandomEntries(
api: ImmichAPI,
now: Date,
count: Int,
filter: SearchFilter = Album.NONE.filter,
subtitle: String? = nil
)
async throws -> [ImageEntry]
{
var entries: [ImageEntry] = []
let randomAssets = try await api.fetchSearchResults(with: filter)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {
group.addTask {
return try? await ImageEntry.build(
api: api,
asset: asset,
dateOffset: dateOffset,
subtitle: subtitle
)
}
}
for await result in group {
if let entry = result {
entries.append(entry)
}
}
}
return entries
}

View File

@@ -1,14 +1,22 @@
import SwiftUI
import WidgetKit
extension Image {
@ViewBuilder
func tintedWidgetImageModifier() -> some View {
if #available(iOS 18.0, *) {
self
.widgetAccentedRenderingMode(.accentedDesaturated)
} else {
self
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var subtitle: String? = nil
var error: WidgetError? = nil
// Resizes the stored image to a maximum width of 450 pixels
mutating func resize() {
if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) {
return
}
image = image?.resized(toWidth: 450)
if image == nil {
error = .unableToResize
}
}
}
@@ -16,12 +24,27 @@ extension Image {
struct ImmichWidgetView: View {
var entry: ImageEntry
func getErrorText(_ error: WidgetError?) -> String {
switch error {
case .noLogin:
return "Login to Immich"
case .fetchFailed:
return "Unable to connect to your Immich instance"
case .albumNotFound:
return "Album not found"
default:
return "An unknown error occured"
}
}
var body: some View {
if entry.image == nil {
VStack {
Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "")
Text(getErrorText(entry.error))
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
@@ -32,13 +55,11 @@ struct ImmichWidgetView: View {
Color.clear.overlay(
Image(uiImage: entry.image!)
.resizable()
.tintedWidgetImageModifier()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle = entry.metadata.subtitle {
if let subtitle = entry.subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(8)
@@ -49,7 +70,6 @@ struct ImmichWidgetView: View {
}
.padding(16)
}
.widgetURL(entry.metadata.deepLink)
}
}
}
@@ -64,9 +84,7 @@ struct ImmichWidgetView: View {
ImageEntry(
date: date,
image: UIImage(named: "ImmichLogo"),
metadata: EntryMetadata(
subtitle: "1 year ago"
)
subtitle: "1 year ago"
)
}
)

View File

@@ -2,38 +2,12 @@ import Foundation
import SwiftUI
import WidgetKit
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable {
enum WidgetError: Error {
case noLogin
case fetchFailed
case unknown
case albumNotFound
case noAssetsAvailable
}
enum FetchError: Error {
case unableToResize
case invalidImage
case invalidURL
case fetchFailed
}
extension WidgetError: LocalizedError {
public var errorDescription: String? {
switch self {
case .noLogin:
return "Login to Immich"
case .fetchFailed:
return "Unable to connect to your Immich instance"
case .albumNotFound:
return "Album not found"
case .noAssetsAvailable:
return "No assets available"
}
}
}
enum AssetType: String, Codable {
@@ -43,25 +17,20 @@ enum AssetType: String, Codable {
case other = "OTHER"
}
struct Asset: Codable {
struct SearchResult: Codable {
let id: String
let type: AssetType
var deepLink: URL? {
return URL(string: "immich://asset?id=\(id)")
}
}
struct SearchFilter: Codable {
var type = AssetType.image
var size = 1
struct SearchFilters: Codable {
var type: AssetType = .image
let size: Int
var albumIds: [String] = []
var isFavorite: Bool? = nil
}
struct MemoryResult: Codable {
let id: String
var assets: [Asset]
var assets: [SearchResult]
let type: String
struct MemoryData: Codable {
@@ -71,34 +40,9 @@ struct MemoryResult: Codable {
let data: MemoryData
}
struct Album: Codable, Equatable {
struct Album: Codable {
let id: String
let albumName: String
static let NONE = Album(id: "NONE", albumName: "None")
static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites")
var filter: SearchFilter {
switch self {
case Album.NONE:
return SearchFilter()
case Album.FAVORITES:
return SearchFilter(isFavorite: true)
// regular album
default:
return SearchFilter(albumIds: [id])
}
}
var isVirtual: Bool {
switch self {
case Album.NONE, Album.FAVORITES:
return true
default:
return false
}
}
}
// MARK: API
@@ -112,7 +56,7 @@ class ImmichAPI {
init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token")
else {
@@ -156,9 +100,8 @@ class ImmichAPI {
return components?.url
}
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
-> [Asset]
func fetchSearchResults(with filters: SearchFilters) async throws
-> [SearchResult]
{
// get URL
guard
@@ -178,7 +121,7 @@ class ImmichAPI {
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([Asset].self, from: data)
return try JSONDecoder().decode([SearchResult].self, from: data)
}
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
@@ -203,7 +146,7 @@ class ImmichAPI {
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
func fetchImage(asset: SearchResult) async throws -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
@@ -214,31 +157,16 @@ class ImmichAPI {
params: thumbnailParams
)
else {
throw .invalidURL
throw URLError(.badURL)
}
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
else {
throw .invalidURL
let (data, _) = try await URLSession.shared.data(from: fetchURL)
guard let img = UIImage(data: data) else {
throw URLError(.badServerResponse)
}
let decodeOptions: [NSString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
imageSource,
0,
decodeOptions as CFDictionary
)
else {
throw .fetchFailed
}
return UIImage(cgImage: thumbnail)
return img
}
func fetchAlbums() async throws -> [Album] {

View File

@@ -2,11 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

Some files were not shown because too many files have changed in this diff Show More