diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index ae0368861abfe..0b0cfbafd918f 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,14 @@ blank_issues_enabled: false contact_links: - - name: I have a question or need support + - name: ✋ I have a question or need support url: https://discord.immich.app about: We use GitHub for tracking bugs, please check out our Discord channel for freaky fast support. - - name: Feature Request + - name: 📷 My photo or video has a date, time, or timezone problem + url: https://github.com/immich-app/immich/discussions/12650 + about: Upload a sample file to this discussion and we will take a look + - name: 🌟 Feature request url: https://github.com/immich-app/immich/discussions/new?category=feature-request about: Please use our GitHub Discussion for making feature requests. - - name: I'm unsure where to go + - name: 🫣 I'm unsure where to go url: https://discord.immich.app about: If you are unsure where to go, then joining our Discord is recommended; Just ask! diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 2c7d1708395e2..0000000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: - # Maintain dependencies for GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" diff --git a/.github/labeler.yml b/.github/labeler.yml index 2a9abc7840381..c0c52f1d7e4b9 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -35,6 +35,4 @@ documentation: - machine-learning/app/** changelog:translation: - - changed-files: - - any-glob-to-any-file: - - web/src/lib/i18n/*.json + - head-branch: ['^chore/translations$'] diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 5292075cce902..94d33b600663b 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -59,7 +59,7 @@ jobs: uses: docker/setup-qemu-action@v3.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.6.1 + uses: docker/setup-buildx-action@v3.7.0 - name: Login to GitHub Container Registry uses: docker/login-action@v3 @@ -88,7 +88,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: file: cli/Dockerfile platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker-cleanup.yml b/.github/workflows/docker-cleanup.yml index bd0ec91d14d86..6f2c573a9f435 100644 --- a/.github/workflows/docker-cleanup.yml +++ b/.github/workflows/docker-cleanup.yml @@ -22,7 +22,7 @@ concurrency: jobs: cleanup-images: name: Cleanup Stale Images Tags for ${{ matrix.primary-name }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: @@ -48,7 +48,7 @@ jobs: cleanup-untagged-images: name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 needs: - cleanup-images strategy: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8a2ba9f841434..b9e7138f9514f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -43,7 +43,7 @@ jobs: retag_ml: name: Re-Tag ML needs: pre-job - if: ${{ needs.pre-job.outputs.should_run_ml == 'false' }} + if: ${{ needs.pre-job.outputs.should_run_ml == 'false' && !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest strategy: matrix: @@ -51,8 +51,6 @@ jobs: steps: - name: Login to GitHub Container Registry uses: docker/login-action@v3 - # Skip when PR from a fork - if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -68,7 +66,7 @@ jobs: retag_server: name: Re-Tag Server needs: pre-job - if: ${{ needs.pre-job.outputs.should_run_server == 'false' }} + if: ${{ needs.pre-job.outputs.should_run_server == 'false' && !github.event.pull_request.head.repo.fork }} runs-on: ubuntu-latest strategy: matrix: @@ -76,8 +74,6 @@ jobs: steps: - name: Login to GitHub Container Registry uses: docker/login-action@v3 - # Skip when PR from a fork - if: ${{ !github.event.pull_request.head.repo.fork }} with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -128,7 +124,7 @@ jobs: uses: docker/setup-qemu-action@v3.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.6.1 + uses: docker/setup-buildx-action@v3.7.0 - name: Login to Docker Hub # Only push to Docker Hub when making a release @@ -177,7 +173,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: context: ${{ env.context }} file: ${{ env.file }} @@ -219,7 +215,7 @@ jobs: uses: docker/setup-qemu-action@v3.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.6.1 + uses: docker/setup-buildx-action@v3.7.0 - name: Login to Docker Hub # Only push to Docker Hub when making a release @@ -268,7 +264,7 @@ jobs: fi - name: Build and push image - uses: docker/build-push-action@v6.7.0 + uses: docker/build-push-action@v6.9.0 with: context: ${{ env.context }} file: ${{ env.file }} diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index 1557b3d15cfba..754d4096132b6 100644 --- a/.github/workflows/pr-label-validation.yml +++ b/.github/workflows/pr-label-validation.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest permissions: issues: write - pull-requests: read + pull-requests: write steps: - name: Require PR to have a changelog label uses: mheap/github-action-required-labels@v5 diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 94567c1cd567e..196f8faf599ba 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -56,6 +56,10 @@ jobs: run: dart format lib/ --set-exit-if-changed working-directory: ./mobile + - name: Run dart custom_lint + run: dart run custom_lint + working-directory: ./mobile + # Enable after riverpod generator migration is completed # - name: Run dart custom lint # run: dart run custom_lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24e3e086235f0..064e3c27616f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,7 +80,7 @@ jobs: run: npm run check if: ${{ !cancelled() }} - - name: Run unit tests & coverage + - name: Run small tests & coverage run: npm run test:cov if: ${{ !cancelled() }} @@ -243,6 +243,26 @@ jobs: run: npm run check if: ${{ !cancelled() }} + medium-tests-server: + name: Medium Tests (Server) + needs: pre-job + if: ${{ needs.pre-job.outputs.should_run_server == 'true' }} + runs-on: mich + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Production build + if: ${{ !cancelled() }} + run: docker compose -f e2e/docker-compose.yml build + + - name: Run medium tests + if: ${{ !cancelled() }} + run: make test-medium + e2e-tests-server-cli: name: End-to-End Tests (Server & CLI) needs: pre-job diff --git a/.vscode/launch.json b/.vscode/launch.json index 7a88b7f3e12d4..ed3da9f667d01 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,8 +5,8 @@ "type": "node", "request": "attach", "restart": true, - "port": 9230, - "name": "Immich Server", + "port": 9231, + "name": "Immich API Server", "remoteRoot": "/usr/src/app", "localRoot": "${workspaceFolder}/server" }, @@ -14,8 +14,8 @@ "type": "node", "request": "attach", "restart": true, - "port": 9231, - "name": "Immich Microservices", + "port": 9230, + "name": "Immich Workers", "remoteRoot": "/usr/src/app", "localRoot": "${workspaceFolder}/server" } diff --git a/Makefile b/Makefile index 349a5c5e920ef..2096cf86df09c 100644 --- a/Makefile +++ b/Makefile @@ -66,6 +66,18 @@ test-e2e: docker compose -f ./e2e/docker-compose.yml build npm --prefix e2e run test npm --prefix e2e run test:web +test-medium: + docker run \ + --rm \ + -v ./server/src:/usr/src/app/src \ + -v ./server/test:/usr/src/app/test \ + -v ./server/vitest.config.medium.mjs:/usr/src/app/vitest.config.medium.mjs \ + -v ./server/tsconfig.json:/usr/src/app/tsconfig.json \ + -e NODE_ENV=development \ + immich-server:latest \ + -c "npm ci && npm run test:medium -- --run" +test-medium-dev: + docker exec -it immich_server /bin/sh -c "npm run test:medium" build-all: $(foreach M,$(MODULES),build-$M) ; install-all: $(foreach M,$(MODULES),install-$M) ; diff --git a/README.md b/README.md index 44c38e6d14813..5c4b9c39edd1d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Português Brasileiro Svenska العربية +Tiếng Việt

diff --git a/cli/.nvmrc b/cli/.nvmrc index 3516580bbbc04..2a393af592b8c 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/cli/Dockerfile b/cli/Dockerfile index e3cce6d448249..b08aba9d3c2b5 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS core +FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 AS core WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ diff --git a/cli/package-lock.json b/cli/package-lock.json index f443c141b9e06..17a99b01f16b1 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.23", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -24,7 +24,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -52,14 +52,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.117.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "typescript": "^5.3.3" } }, @@ -765,6 +765,16 @@ "node": "*" } }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", @@ -825,9 +835,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", - "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", "dev": true, "license": "MIT", "engines": { @@ -844,6 +854,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1299,6 +1322,13 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.0", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", @@ -1324,9 +1354,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "dev": true, "license": "MIT", "dependencies": { @@ -1340,17 +1370,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", - "integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/type-utils": "8.3.0", - "@typescript-eslint/utils": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1374,16 +1404,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", - "integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4" }, "engines": { @@ -1403,14 +1433,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", - "integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1421,14 +1451,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", - "integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/utils": "8.3.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1446,9 +1476,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", - "integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", "dev": true, "license": "MIT", "engines": { @@ -1460,14 +1490,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", - "integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1489,16 +1519,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", - "integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0" + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1512,13 +1542,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", - "integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1530,19 +1560,20 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.2.tgz", + "integrity": "sha512-b7kHrFrs2urS0cOk5N10lttI8UdJ/yP3nB4JYTREvR5o18cR99yPpK4gK8oQgI42BVv0ILWYUSYB7AXkAUDc0g==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -1552,17 +1583,24 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.2.tgz", + "integrity": "sha512-FEgtlN8mIUSEAAnlvn7mP8vzaWhEaAEvhSXCqrsijM7K6QqjB11qoRZYEd4AKSCDz8p0/+yH5LzhZ47qt+EyPg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1570,11 +1608,40 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.2.tgz", + "integrity": "sha512-ExElkCGMS13JAJy+812fw1aCv2QO/LBK6CyO4WOPAzLTmve50gydOlWhgdBJPx2ztbADUq3JVI0C5U+bShaeEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.2", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.2.tgz", + "integrity": "sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==", "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^1.2.0" }, @@ -1583,12 +1650,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.2.tgz", + "integrity": "sha512-UCsPtvluHO3u7jdoONGjOSil+uON5SSvU9buQh3lP7GgUXHp78guN1wRmZDX4wGK6J10f9NUtP6pO+SFquoMlw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -1596,13 +1664,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.2.tgz", + "integrity": "sha512-xtAeNsZ++aRIYIUsek7VHzry/9AcxeULlegBvsdLncLmNCR6tR8SRjn8BbDP4naxtccvzTqZ+L1ltZlRCfBZFA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.2", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -1610,10 +1679,11 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.2.tgz", + "integrity": "sha512-GSUi5zoy+abNRJwmFhBDC0yRuVUn8WMlQscvnbbXdKLXX9dE59YbfwXxuJ/mth6eeqIzofU8BB5XDo/Ns/qK2A==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^3.0.0" }, @@ -1622,13 +1692,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.2.tgz", + "integrity": "sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -1710,6 +1780,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -1800,6 +1871,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1838,6 +1910,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -1870,6 +1943,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" } @@ -2014,6 +2088,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2112,20 +2187,24 @@ } }, "node_modules/eslint": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz", - "integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.9.1", + "@eslint/js": "9.11.1", + "@eslint/plugin-kit": "^0.2.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -2145,7 +2224,6 @@ "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", @@ -2277,6 +2355,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2289,9 +2374,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2383,6 +2468,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -2396,29 +2482,6 @@ "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2577,27 +2640,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2631,9 +2673,9 @@ } }, "node_modules/globals": { - "version": "15.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", - "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "version": "15.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.10.0.tgz", + "integrity": "sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ==", "dev": true, "license": "MIT", "engines": { @@ -2688,15 +2730,6 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2818,18 +2851,6 @@ "node": ">=8" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3012,13 +3033,11 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "10.4.3", @@ -3061,12 +3080,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3087,18 +3100,6 @@ "node": ">=8.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -3134,10 +3135,11 @@ } }, "node_modules/mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.3.0.tgz", + "integrity": "sha512-IMvz1X+RF7vf+ur7qUenXMR7/FSKSIqS3HqFHXcyNI7G0FbpFO8L5lfsUJhl+bhK1AiulVHWKUSxebWauPA+xQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.0.0" } @@ -3219,48 +3221,6 @@ "semver": "bin/semver" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -3397,21 +3357,23 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.16" } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -3436,9 +3398,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -3457,8 +3419,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3502,21 +3464,17 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "license": "MIT", "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } @@ -3827,10 +3785,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -3933,18 +3892,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4031,10 +3978,18 @@ "dev": true }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true, + "license": "MIT" }, "node_modules/tinypool": { "version": "1.0.0", @@ -4055,10 +4010,11 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -4140,9 +4096,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4210,14 +4166,14 @@ } }, "node_modules/vite": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", - "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.41", + "postcss": "^8.4.43", "rollup": "^4.20.0" }, "bin": { @@ -4270,15 +4226,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.2.tgz", + "integrity": "sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -4312,29 +4268,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.2", + "@vitest/mocker": "2.1.2", + "@vitest/pretty-format": "^2.1.2", + "@vitest/runner": "2.1.2", + "@vitest/snapshot": "2.1.2", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4349,8 +4306,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, @@ -4535,9 +4492,9 @@ } }, "node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", "dev": true, "license": "ISC", "bin": { diff --git a/cli/package.json b/cli/package.json index 0d560c8456585..03d248feb8bf4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.23", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", @@ -20,7 +20,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -67,6 +67,6 @@ "lodash-es": "^4.17.21" }, "volta": { - "node": "20.17.0" + "node": "20.18.0" } } diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl index 096177bb05366..6419c16dad324 100644 --- a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.40.0" - constraints = "4.40.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", - "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", - "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", - "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", - "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", - "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", - "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", - "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", - "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", - "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", - "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", - "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", - "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", - "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", - "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", - "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", - "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", - "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", - "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", - "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", - "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", - "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", - "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", - "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", - "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", - "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", - "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf index 63c96fc49805b..74ea6d5816f8e 100644 --- a/deployment/modules/cloudflare/docs-release/config.tf +++ b/deployment/modules/cloudflare/docs-release/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.40.0" + version = "4.43.0" } } } diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl index 096177bb05366..6419c16dad324 100644 --- a/deployment/modules/cloudflare/docs/.terraform.lock.hcl +++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl @@ -2,37 +2,37 @@ # Manual edits may be lost in future updates. provider "registry.opentofu.org/cloudflare/cloudflare" { - version = "4.40.0" - constraints = "4.40.0" + version = "4.43.0" + constraints = "4.43.0" hashes = [ - "h1:GP2N1tXrmpxu+qEDvFAmkfv9aeZNhag3bchyJpGpYbU=", - "h1:HDJKZBQkVU0kQl4gViQ5L7EcFLn9hB0iuvO+ORJiDS4=", - "h1:KrbeEsZoCJOnnX68yNI5h3QhMjc5bBCQW4yvYaEFq3s=", - "h1:LelwnzU0OVn6g2+T9Ub9XdpC+vbheraIL/qgXhWBs/k=", - "h1:TIq9CynfWrKgCxKL97Akj89cYlvJKn/AL4UXogd8/FM=", - "h1:Uoy5oPdm1ipDG7yIMCUN1IXMpsTGXahPw3I0rVA/6wA=", - "h1:Wunfpm+IZhENdoimrh4iXiakVnCsfKOHo80yJUjMQXM=", - "h1:cRdCuahMOFrNyldnCInqGQRBT1DTkRPSfPnaf5r05iw=", - "h1:k+zpXg8BO7gdbTIfSGyQisHhs5aVWQVbPLa5uUdr2UA=", - "h1:kWNrzZ8Rh0OpHikexkmwJIIucD6SMZPi4oGyDsKJitw=", - "h1:lomfTTjK78BdSEVTFcJUBQRy7IQHuGQImMaPWaYpfgQ=", - "h1:oWcWlZe52ZRyLQciNe94RaWzhHifSTu03nlK0uL7rlM=", - "h1:p3JJrhGEPlPQP7Uwy9FNMdvqCyD8tuT4lnXuJ+pSF/M=", - "h1:wtB0sKxG2K/H41hWJI4uJdImWquuaP34Sip5LmfE410=", - "zh:01742e5946f936548f8e42120287ffc757abf97e7cbbe34e25c266a438fb54fd", - "zh:08d81f5a5aab4cc269f983b8c6b5be0e278105136aca9681740802619577371f", - "zh:0d75131ba70902cfc94a7a5900369bdde56528b2aad6e10b164449cc97d57396", - "zh:3890a715a012e197541daacdacb8cceec6d364814daa4640ddfe98a8ba9036cb", - "zh:58254ce5ebe1faed4664df86210c39d660bcdc60280f17b25fe4d4dbea21ea8c", - "zh:6b0abc1adbc2edee79368ce9f7338ebcb5d0bf941e8d7d9ac505b750f20f80a2", - "zh:81cc415d1477174a1ca288d25fdb57e5ee488c2d7f61f265ef995b255a53b0ce", - "zh:8680140c7fe5beaefe61c5cfa471bf88422dc0c0f05dad6d3cb482d4ffd22be4", + "h1:2kDVLD36BOVgBzI9p0WIQ+xjFfMmjaItA0l8SyZWEPo=", + "h1:2sGJDAwFEgO8+3y+2suYO+yrjNOzSsihad0hbM3+jPg=", + "h1:A1WPQFcdD+7FrFBFrKcx4CiSr75xSmsO93C0e5NBAeQ=", + "h1:BuXs/1ohmF4fWyOErY6vNbm7DaEIfbLSepSiZ2ol9I8=", + "h1:QPh+X19oyo808sqdeJaVqahZcQgcG1jCi3DA5zpjz6U=", + "h1:RI7c7dhSJoIkfou5b8ITRpM5MqsQD3FULj1h/rI4rJk=", + "h1:gdI5JTCPjewdGq1bhGAs+V5qCcmJ73N2gtMfuFybJp4=", + "h1:h4lnJpCIYZ7dsN9IO2mmwNdWNiQYEPoAEUjLF2sZ5kc=", + "h1:jTaExrX/eR7vGT5wayGqH8ZtXS2zyk0WmD3zbAKFIQU=", + "h1:l5NKJUOQJ1mHl1eekeXaxUZ+g+8Yv4aGcIN9vuK6GL4=", + "h1:sNbvm66/2vc8B/khyioOO8eNaU8nb89x693AN7fQheU=", + "h1:tXS4g1yE420AU4mvZ7RrYI+yYTutkRID3l+W0gBH4BM=", + "h1:vA+kES7uqmKA9K0U45IXR94jaTQZCHZLCHqMUeGxKMI=", + "h1:zV131k79+ob9p4jrLDgztDNvZvt8fvrrzpn0nPikBw8=", + "zh:006d111d6eafe6eeb5df2f91bd0ca320f979bd71f8cd8c475f10b2bd94acba55", + "zh:031fbb5cac23a841dc18e270cbfcd3ce9f4ba504edbd3c78931f7ed9827220a8", + "zh:07a72fe8b55afee99529bf4169ab6abfac5eabcd10968c29101925bcd358b09f", + "zh:0d14727d011c2d9df4c3058f527d2409223449ab48b46cbc86922eb553ef77c1", + "zh:155ce1333672d26cd18a5866b0761489d91682beffee58e45c3a1b68e8491d3d", + "zh:35a2a1939a965335b29ebdbfd759d93a97c0f589d9cd218f537dee6f600e3fb9", + "zh:52912fe421e7d911431f77788db2ea13836efd65a2e82385adb52c6a84d4ee90", + "zh:57374318d9194ea1db08884b0541a9055823d5970ad48f9a57547ac231163007", + "zh:5fb942b9e2553c058fe09fe12fb39dd175cd6715bb41c059c1a70df2bfc64dc1", + "zh:63cabd2bda201b09b35a3279d1f813ab71394b9b90fc5cf8962a5eba207803bc", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", - "zh:a491d26236122ccb83dac8cb490d2c0aa1f4d3a0b4abe99300fd49b1a624f42f", - "zh:a70d9c469dc8d55715ba77c9d1a4ede1fdebf79e60ee18438a0844868db54e0d", - "zh:a7fcb7d5c4222e14ec6d9a15adf8b9a083d84b102c3d0e4a0d102df5a1360b62", - "zh:b4f9677174fabd199c8ebd2e9e5eb3528cf887e700569a4fb61eef4e070cec5e", - "zh:c27f0f7519221d75dae4a3787a59e05acd5cc9a0d30a390eff349a77d20d52e6", - "zh:db00d8605dbf43ca42fe1481a6c67fdcaa73debb7d2a0f613cb95ae5c5e7150e", + "zh:978ee67d3d53970a5c474ab40b00adee97f4153b16804a2b6b7ee205ae69d18a", + "zh:bbafdbef631b5c80570087817b42b16b1a76d556d692853a71c47fb48663cf00", + "zh:be91b3f2a697cbbb41f65aad2600972d0ede1e962a7d8a00bb3177cb77d86666", + "zh:efe168ad4aaa6156ce5a31d4e50e9d54d38ee5a5888412f9e690c0de5d619683", ] } diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf index 63c96fc49805b..74ea6d5816f8e 100644 --- a/deployment/modules/cloudflare/docs/config.tf +++ b/deployment/modules/cloudflare/docs/config.tf @@ -5,7 +5,7 @@ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" - version = "4.40.0" + version = "4.43.0" } } } diff --git a/deployment/modules/cloudflare/docs/domain.tf b/deployment/modules/cloudflare/docs/domain.tf index 80997c2e87176..a28fb4c0f80a9 100644 --- a/deployment/modules/cloudflare/docs/domain.tf +++ b/deployment/modules/cloudflare/docs/domain.tf @@ -18,7 +18,7 @@ output "immich_app_branch_subdomain" { } output "immich_app_branch_pages_hostname" { - value = cloudflare_record.immich_app_branch_subdomain.value + value = cloudflare_record.immich_app_branch_subdomain.content } output "pages_project_name" { diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 16ed032dfb1c1..3d0f72425faca 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -36,6 +36,10 @@ services: IMMICH_BUILD_URL: https://github.com/immich-app/immich/actions/runs/9654404849 IMMICH_BUILD_IMAGE: development IMMICH_BUILD_IMAGE_URL: https://github.com/immich-app/immich/pkgs/container/immich-server + IMMICH_THIRD_PARTY_SOURCE_URL: https://github.com/immich-app/immich/ + IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues + IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://immich.app/docs + IMMICH_THIRD_PARTY_SUPPORT_URL: https://immich.app/docs/third-party ulimits: nofile: soft: 1048576 @@ -43,6 +47,7 @@ services: ports: - 3001:3001 - 9230:9230 + - 9231:9231 depends_on: - redis - database @@ -98,7 +103,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 3e62e7f5619ab..299516d16f07d 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -47,7 +47,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 restart: always @@ -91,7 +91,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.2.0-ubuntu@sha256:8e2c13739563c3da9d45de96c6bcb63ba617cac8c571c060112c7fc8ad6914e9 + image: grafana/grafana:11.2.2-ubuntu@sha256:2bef00403c18d27919ff19d64fd6253fa713b3880304e92f69109e14221ac843 volumes: - grafana-data:/var/lib/grafana diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2f7d41271dcdd..3072c96c58b3b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -48,7 +48,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index bd4e2a46b8b39..33fb7b3c06273 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -51,5 +51,4 @@ services: volumes: - /usr/lib/wsl:/usr/lib/wsl environment: - - LD_LIBRARY_PATH=/usr/lib/wsl/lib - LIBVA_DRIVER_NAME=d3d12 diff --git a/docs/.nvmrc b/docs/.nvmrc index 3516580bbbc04..2a393af592b8c 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index b1a24e1788a2f..b328d3a047099 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -187,7 +187,7 @@ However, when the trash is emptied, the files will re-appear in the main timelin ### How does smart search work? -Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). +Immich uses CLIP models. An ML model converts each image to an "embedding", which is essentially a string of numbers that semantically encodes what is in the image. The same is done for the text that you enter when you do a search, and that text embedding is then compared with those of the images to find similar ones. As such, there are no "tags", "labels", or "descriptions" generated that you can look at. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). ### How does facial recognition work? @@ -333,7 +333,11 @@ You may need to add mount points or docker volumes for the following internal co - `immich-machine-learning:/.cache` - `redis:/data` -The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`. +The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION` and `/cache` for machine-learning. + +:::note Docker Compose Volumes +The Docker Compose top level volume element does not support non-root access, all of the above volumes must be local volume mounts. +::: For a further hardened system, you can add the following block to every container except for `immich_postgres`. diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 3d226dd0615df..19d716643b414 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -21,6 +21,8 @@ The recommended way to backup and restore the Immich database is to use the `pg_ It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored. ::: +### Manual Backup and Restore + @@ -29,16 +31,18 @@ docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgre ``` ```bash title='Restore' -docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. -# rm -rf DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch. -docker compose pull # Update to latest version of Immich (if desired) -docker compose create # Create Docker containers for Immich apps without running them. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch +## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database +# rm -rf DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch +docker compose pull # Update to latest version of Immich (if desired) +docker compose create # Create Docker containers for Immich apps without running them docker start immich_postgres # Start Postgres server -sleep 10 # Wait for Postgres server to start up +sleep 10 # Wait for Postgres server to start up +# Check the database user if you deviated from the default gunzip < "/path/to/backup/dump.sql.gz" \ | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \ -| docker exec -i immich_postgres psql --username=postgres # Restore Backup -docker compose up -d # Start remainder of Immich apps +| docker exec -i immich_postgres psql --username=postgres # Restore Backup +docker compose up -d # Start remainder of Immich apps ``` @@ -49,14 +53,16 @@ docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgre ``` ```powershell title='Restore' -docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. -# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch. -docker compose pull # Update to latest version of Immich (if desired) -docker compose create # Create Docker containers for Immich apps without running them. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch +## Uncomment the next line and replace DB_DATA_LOCATION with your Postgres path to permanently reset the Postgres database +# Remove-Item -Recurse -Force DB_DATA_LOCATION # CAUTION! Deletes all Immich data to start from scratch +docker compose pull # Update to latest version of Immich (if desired) +docker compose create # Create Docker containers for Immich apps without running them docker start immich_postgres # Start Postgres server -sleep 10 # Wait for Postgres server to start up -gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup -docker compose up -d # Start remainder of Immich apps +sleep 10 # Wait for Postgres server to start up +# Check the database user if you deviated from the default +gc "C:\path\to\backup\dump.sql" | docker exec -i immich_postgres psql --username=postgres # Restore Backup +docker compose up -d # Start remainder of Immich apps ``` @@ -68,6 +74,8 @@ Note that for the database restore to proceed properly, it requires a completely Some deployment methods make it difficult to start the database without also starting the server or microservices. In these cases, you may set the environmental variable `DB_SKIP_MIGRATIONS=true` before starting the services. This will prevent the server from running migrations that interfere with the restore process. Note that both the server and microservices must have this variable set to prevent the migrations from running. Be sure to remove this variable and restart the services after the database is restored. ::: +### Automatic Database Backups + The database dumps can also be automated (using [this image](https://github.com/prodrigestivill/docker-postgres-backup-local)) by editing the docker compose file to match the following: ```yaml @@ -97,6 +105,7 @@ services: Then you can restore with the same command but pointed at the latest dump. ```bash title='Automated Restore' +# Be sure to check the username if you changed it from default gunzip < db_dumps/last/immich-latest.sql.gz \ | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \ | docker exec -i immich_postgres psql --username=postgres @@ -157,7 +166,7 @@ for more info read the [release notes](https://github.com/immich-app/immich/rele - 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 `UPLOAD_LOCATION/postgres`. + - Stored in `DB_DATA_LOCATION`. :::danger A backup of this folder does not constitute a backup of your database! @@ -203,7 +212,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO - 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 `UPLOAD_LOCATION/postgres`. + - Stored in `DB_DATA_LOCATION`. :::danger A backup of this folder does not constitute a backup of your database! diff --git a/docs/docs/administration/email-notification.mdx b/docs/docs/administration/email-notification.mdx index 4a2a0b5a837f7..93b1051053069 100644 --- a/docs/docs/administration/email-notification.mdx +++ b/docs/docs/administration/email-notification.mdx @@ -8,13 +8,11 @@ Immich supports the option to send notifications via Email for the following eve ## SMTP settings -You can access the settings panel from the web at `Administration -> Settings -> Notification settings` +You can access the settings panel from the web at `Administration -> Settings -> Notification settings`. -Under Email, enter the following details to connect with SMTP servers. +Under Email, enter the required details to connect with an SMTP server. -You can use the following [guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. - - +You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server. ## User's notifications settings diff --git a/docs/docs/administration/img/email-settings.png b/docs/docs/administration/img/email-settings.png deleted file mode 100644 index a0d71354267fb..0000000000000 Binary files a/docs/docs/administration/img/email-settings.png and /dev/null differ diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index b5028c788e422..798555975f74c 100644 --- a/docs/docs/administration/postgres-standalone.md +++ b/docs/docs/administration/postgres-standalone.md @@ -13,9 +13,9 @@ Running with a pre-existing Postgres server can unlock powerful administrative f You must install pgvecto.rs into your instance of Postgres using their [instructions][vectors-install]. After installation, add `shared_preload_libraries = 'vectors.so'` to your `postgresql.conf`. If you already have some `shared_preload_libraries` set, you can separate each extension with a comma. For example, `shared_preload_libraries = 'pg_stat_statements, vectors.so'`. :::note -Immich is known to work with Postgres versions 14, 15, and 16. Earlier versions are unsupported. +Immich is known to work with Postgres versions 14, 15, and 16. Earlier versions are unsupported. Postgres 17 is nominally compatible, but pgvecto.rs does not have prebuilt images or packages for it as of writing. -Make sure the installed version of pgvecto.rs is compatible with your version of Immich. For example, if your Immich version uses the dedicated database image `tensorchord/pgvecto-rs:pg14-v0.2.1`, you must install pgvecto.rs `>= 0.2.1, < 0.3.0`. +Make sure the installed version of pgvecto.rs is compatible with your version of Immich. The current accepted range for pgvecto.rs is `>= 0.2.0, < 0.4.0`. ::: ## Specifying the connection URL diff --git a/docs/docs/administration/reverse-proxy.md b/docs/docs/administration/reverse-proxy.md index 1d2488f1192d8..c40fecbdc4c23 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -64,3 +64,43 @@ Below is an example config for Apache2 site configuration. ProxyPreserveHost On ``` + +### Traefik Proxy example config + +The example below is for Traefik version 3. + +The most important is to increase the `respondingTimeouts` of the entrypoint used by immich. In this example of entrypoint `websecure` for port `443`. Per default it's set to 60s which leeds to videos stop uploading after 1 minute (Error Code 499). With this config it will fail after 10 minutes which is in most cases enough. Increase it if needed. + +`traefik.yaml` + +```yaml +[...] +entryPoints: + websecure: + address: :443 + # this section needs to be added + transport: + respondingTimeouts: + readTimeout: 600s + idleTimeout: 600s + writeTimeout: 600s +``` + +The second part is in the `docker-compose.yml` file where immich is in. Add the Traefik specific labels like in the example. + +`docker-compose.yml` + +```yaml +services: + immich-server: + [...] + labels: + traefik.enable: true + # increase readingTimeouts for the entrypoint used here + traefik.http.routers.immich.entrypoints: websecure + traefik.http.routers.immich.rule: Host(`immich.your-domain.com`) + traefik.http.services.immich.loadbalancer.server.port: 3001 +``` + +Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done +by adding the Traefik network to the `immich-server`. diff --git a/docs/docs/administration/system-integrity.md b/docs/docs/administration/system-integrity.md new file mode 100644 index 0000000000000..5440e42489d83 --- /dev/null +++ b/docs/docs/administration/system-integrity.md @@ -0,0 +1,47 @@ +# System Integrity + +## Folder checks + +:::info +The folders considered for these checks include: `upload/`, `library/`, `thumbs/`, `encoded-video/`, `profile/` +::: + +When Immich starts, it performs a series of checks in order to validate that it can read and write files to the volume mounts used by the storage system. If it cannot perform all the required operations, it will fail to start. The checks include: + +- Creating an initial hidden file (`.immich`) in each folder +- Reading a hidden file (`.immich`) in each folder +- Overwriting a hidden file (`.immich`) in each folder + +The checks are designed to catch the following situations: + +- Incorrect permissions (cannot read/write files) +- Missing volume mount (`.immich` files should exist, but are missing) + +### Common issues + +:::note +`.immich` files serve as markers and help keep track of volume mounts being used by Immich. Except for the situations listed below, they should never be manually created or deleted. +::: + +#### Missing `.immich` files + +``` +Verifying system mount folder checks (enabled=true) +... +ENOENT: no such file or directory, open 'upload/encoded-video/.immich' +``` + +The above error messages show that the server has previously (successfully) written `.immich` files to each folder, but now does not detect them. This could be because any of the following: + +- Permission error - unable to read the file, but it exists +- File does not exist - volume mount has changed and should be corrected +- File does not exist - user manually deleted it and should be manually re-created (`touch .immich`) +- File does not exist - user restored from a backup, but did not restore each folder (user should restore all folders or manually create `.immich` in any missing folders) + +### Ignoring the checks + +The checks are designed to catch common problems that we have seen users have in the past, but if you want to disable them you can set the following environment variable: + +``` +IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true +``` diff --git a/docs/docs/developer/architecture.mdx b/docs/docs/developer/architecture.mdx index cf004a1119213..7b5debef4c0da 100644 --- a/docs/docs/developer/architecture.mdx +++ b/docs/docs/developer/architecture.mdx @@ -3,6 +3,7 @@ sidebar_position: 1 --- import AppArchitecture from './img/app-architecture.png'; +import MobileArchitecture from './img/immich_mobile_architecture.svg'; # Architecture @@ -28,7 +29,14 @@ All three clients use [OpenAPI](./open-api.md) to auto-generate rest clients for ### Mobile App -The mobile app is written in [Flutter](https://flutter.dev/). It uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management. +The mobile app is written in [Dart](https://dart.dev/) using [Flutter](https://flutter.dev/). Below is an architecture overview: + + + +The diagrams shows the target architecture, the current state of the code-base is not always following the architecture yet. New code and contributions should follow this architecture. +Currently, it uses [Isar Database](https://isar.dev/) for a local database and [Riverpod](https://riverpod.dev/) for state management (providers). +Entities and Models are the two types of data classes used. While entities are stored in the on-device database, models are ephemeral and only kept in memory. +The Repositories should be the only place where other data classes are used internally (such as OpenAPI DTOs). However, their interfaces must not use foreign data classes! ### Web Client diff --git a/docs/docs/developer/img/immich_mobile_architecture.drawio b/docs/docs/developer/img/immich_mobile_architecture.drawio new file mode 100644 index 0000000000000..548cda09383f3 --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.drawio @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/docs/developer/img/immich_mobile_architecture.svg b/docs/docs/developer/img/immich_mobile_architecture.svg new file mode 100644 index 0000000000000..71f28235bf649 --- /dev/null +++ b/docs/docs/developer/img/immich_mobile_architecture.svg @@ -0,0 +1,3 @@ + + +
Mobile App
Mobile App
Services
Services
Repositories
Repositories
Providers
Providers
Pages
Pages
Widgets
Widgets
User
User
platform
system
platform...
on-device
database
on-device...
server
server
OpenAPI
OpenAPI
UI part
UI part
non-UI part
non-UI part
Models
Models
Entities
Entities
\ No newline at end of file diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index 5f8d442aa85e8..32e79849efdcf 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -106,7 +106,7 @@ in User `settings.json` (`cmd + shift + p` and search for `Open User Settings JS "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false, + "editor.wordBasedSuggestions": "off", "editor.defaultFormatter": "Dart-Code.dart-code" } } diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index deba45caccebc..e833bbef4f2b3 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -49,7 +49,7 @@ For RKMPP to work: - You must have a supported Rockchip ARM SoC. - Only RK3588 supports hardware tonemapping, other SoCs use slower software tonemapping while still using hardware encoding. -- Tonemapping requires `/usr/lib/aarch64-linux-gnu/libmali.so.1` to be present on your host system. Install [`libmali-valhall-g610-g6p0-gbm`][libmali-rockchip] and modify the [`hwaccel.transcoding.yml`][hw-file] file: +- Tonemapping requires `/usr/lib/aarch64-linux-gnu/libmali.so.1` to be present on your host system. Install the [`libmali`][libmali-rockchip] release that corresponds to your Mali GPU (`libmali-valhall-g610-g13p0-gbm` on RK3588) and modify the [`hwaccel.transcoding.yml`][hw-file] file: - under `rkmpp` uncomment the 3 lines required for OpenCL tonemapping by removing the `#` symbol at the beginning of each line - `- /dev/mali0:/dev/mali0` - `- /etc/OpenCL:/etc/OpenCL:ro` diff --git a/docs/docs/features/libraries.md b/docs/docs/features/libraries.md index cdea1a11a51e1..17555469543c8 100644 --- a/docs/docs/features/libraries.md +++ b/docs/docs/features/libraries.md @@ -1,41 +1,21 @@ -# Libraries +# External Libraries -## Overview +External libraries track assets stored in the filesystem outside of Immich. When the external library is scanned, Immich will load videos and photos from disk and create the corresponding assets. These assets will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. Later, if a file is modified outside of Immich, you need to scan the library for the changes to show up. -Immich supports the creation of libraries which is a top-level asset container. Currently, there are two types of libraries: traditional upload libraries that can sync with a mobile device, and external libraries, that keeps up to date with files on disk. Libraries are different from albums in that an asset can belong to multiple albums but only one library, and deleting a library deletes all assets contained within. As of August 2023, this is a new feature and libraries have a lot of potential for future development beyond what is documented here. This document attempts to describe the current state of libraries. - -## External Libraries - -External libraries tracks assets stored outside of Immich, i.e. in the file system. When the external library is scanned, Immich will read the metadata from the file and create an asset in the library for each image or video file. These items will then be shown in the main timeline, and they will look and behave like any other asset, including viewing on the map, adding to albums, etc. - -If a file is modified outside of Immich, the changes will not be reflected in immich until the library is scanned again. There are different ways to scan a library depending on the use case: - -- Scan Library Files: This is the default scan method and also the quickest. It will scan all files in the library and add new files to the library. It will notice if any files are missing (see below) but not check existing assets -- Scan All Library Files: Same as above, but will check each existing asset to see if the modification time has changed. If it has, the asset will be updated. Since it has to check each asset, this is slower than Scan Library Files. -- Force Scan All Library Files: Same as above, but will read each asset from disk no matter the modification time. This is useful in some cases where an asset has been modified externally but the modification time has not changed. This is the slowest way to scan because it reads each asset from disk. +If an external asset is deleted from disk, Immich will move it to trash on rescan. To restore the asset, you need to restore the original file. After 30 days the file will be removed from trash, and any changes to metadata within Immich will be lost. :::caution -Due to aggressive caching it can take some time for a refreshed asset to appear correctly in the web view. You need to clear the cache in your browser to see the changes. This is a known issue and will be fixed in a future release. In Chrome, you need to open the developer console with F12, then reload the page with F5, and finally right click on the reload button and select "Empty Cache and Hard Reload". +If you add metadata to an external asset in any way (i.e. add it to an album or edit the description), that metadata is only stored inside Immich and will not be persisted to the external asset file. If you move an asset to another location within the library all such metadata will be lost upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. ::: -In external libraries, the file path is used for duplicate detection. This means that if a file is moved to a different location, it will be added as a new asset. If the file is moved back to its original location, it will be added as a new asset. In contrast to upload libraries, two identical files can be uploaded if they are in different locations. This is a deliberate design choice to make Immich reflect the file system as closely as possible. Remember that duplication detection is only done within the same library, so if you have multiple external libraries, the same file can be added to multiple libraries. - :::caution -If you add assets from an external library to an album and then move the asset to another location within the library, the asset will be removed from the album upon rescan. This is because the asset is considered a new asset after the move. This is a known issue and will be fixed in a future release. +Due to aggressive caching it can take some time for a refreshed asset to appear correctly in the web view. You need to clear the cache in your browser to see the changes. This is a known issue and will be fixed in a future release. In Chrome, you need to open the developer console with F12, then reload the page with F5, and finally right click on the reload button and select "Empty Cache and Hard Reload". ::: -### Deleted External Assets - -Note: Either a manual or scheduled library scan must have been performed to identify offline assets before this process will work. - -In all above scan methods, Immich will check if any files are missing. This can happen if files are deleted, or if they are on a storage location that is currently unavailable, like a network drive that is not mounted, or a USB drive that has been unplugged. In order to prevent accidental deletion of assets, Immich will not immediately delete an asset from the library if the file is missing. Instead, the asset will be internally marked as offline and will still be visible in the main timeline. If the file is moved back to its original location and the library is scanned again, the asset will be restored. - -Finally, files can be deleted from Immich via the `Remove Offline Files` job. This job can be found by the three dots menu for the associated external storage that was configured under Administration > Libraries (the same location described at [create external libraries](#create-external-libraries)). When this job is run, any assets marked as offline will then be removed from Immich. Run this job whenever files have been deleted from the file system and you want to remove them from Immich. - ### Import Paths External libraries use import paths to determine which files to scan. Each library can have multiple import paths so that files from different locations can be added to the same library. Import paths are scanned recursively, and if a file is in multiple import paths, it will only be added once. Each import file must be a readable directory that exists on the filesystem; the import path dialog will alert you of any paths that are not accessible. @@ -66,9 +46,13 @@ Some basic examples: - `**/Raw/**` will exclude all files in any directory named `Raw` - `**/*.{tif,jpg}` will exclude all files with the extension `.tif` or `.jpg` +Special characters such as @ should be escaped, for instance: + +- `**/\@eadir/**` will exclude all files in any directory named `@eadir` + ### Automatic watching (EXPERIMENTAL) -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. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button. +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. @@ -84,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up. ### Nightly job -There is an automatic job that's run once a day and refreshes all modified files in all libraries as well as cleans up any libraries stuck in deletion. +There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. ## Usage @@ -120,7 +104,7 @@ This will disallow the images from being deleted in the web UI, or adding metada _Remember to run `docker compose up -d` to register the changes. Make sure you can see the mounted path in the container._ ::: -### Create External Libraries +### Create A New Library These actions must be performed by the Immich administrator. @@ -144,7 +128,7 @@ Next, we'll add an exclusion pattern to filter out raw files. - Enter `**/Raw/**` and click save. - Click save - Click the drop-down menu on the newly created library -- Click on Scan Library Files +- Click on Scan The christmas trip library will now be scanned in the background. In the meantime, let's add the videos and old photos to another library. @@ -161,7 +145,7 @@ If you get an error here, please rename the other external library to something - Click on Add Path - Enter `/mnt/media/videos` then click Add - Click Save -- Click on Scan Library Files +- Click on Scan Within seconds, the assets from the old-pics and videos folders should show up in the main timeline. diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index b20c3fc2b6315..ca1cb8edb1e99 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -38,7 +38,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele - The GPU must have compute capability 5.2 or greater. - The server must have the official NVIDIA driver installed. -- The installed driver must be >= 545 (it must support CUDA 12.3.2). +- The installed driver must be >= 535 (it must support CUDA 12.2). - On Linux (except for WSL2), you also need to have [NVIDIA Container Toolkit][nvct] installed. #### OpenVINO @@ -53,6 +53,12 @@ You do not need to redo any machine learning jobs after enabling hardware accele 3. Still in `immich-machine-learning`, add one of -[armnn, cuda, openvino] to the `image` section's tag at the end of the line. 4. Redeploy the `immich-machine-learning` container with these updated settings. +### Confirming Device Usage + +You can confirm the device is being recognized and used by checking its utilization. There are many tools to display this, such as `nvtop` for NVIDIA or Intel and `intel_gpu_top` for Intel. + +You can also check the logs of the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, or when you search with text in Immich, you should either see a log for `Available ORT providers` containing the relevant provider (e.g. `CUDAExecutionProvider` in the case of CUDA), or a `Loaded ANN model` log entry without errors in the case of ARM NN. + #### Single Compose File Some platforms, including Unraid and Portainer, do not support multiple Compose files as of writing. As an alternative, you can "inline" the relevant contents of the [`hwaccel.ml.yml`][hw-file] file into the `immich-machine-learning` service directly. @@ -95,9 +101,22 @@ immich-machine-learning: Once this is done, you can redeploy the `immich-machine-learning` container. -:::info -You can confirm the device is being recognized and used by checking its utilization (via `nvtop` for CUDA, `intel_gpu_top` for OpenVINO, etc.). You can also enable debug logging by setting `IMMICH_LOG_LEVEL=debug` in the `.env` file and restarting the `immich-machine-learning` container. When a Smart Search or Face Detection job begins, you should see a log for `Available ORT providers` containing the relevant provider. In the case of ARM NN, the absence of a `Could not load ANN shared libraries` log entry means it loaded successfully. -::: +#### Multi-GPU + +If you want to utilize multiple NVIDIA or Intel GPUs, you can set the `MACHINE_LEARNING_DEVICE_IDS` environmental variable to a comma-separated list of device IDs and set `MACHINE_LEARNING_WORKERS` to the number of listed devices. You can run a command such as `nvidia-smi -L` or `glxinfo -B` to see the currently available devices and their corresponding IDs. + +For example, if you have devices 0 and 1, set the values as follows: + +``` +MACHINE_LEARNING_DEVICE_IDS=0,1 +MACHINE_LEARNING_WORKERS=2 +``` + +In this example, the machine learning service will spawn two workers, one of which will allocate models to device 0 and the other to device 1. Different requests will be processed by one worker or the other. + +This approach can be used to simply specify a particular device as well. For example, setting `MACHINE_LEARNING_DEVICE_IDS=1` will ensure device 1 is always used instead of device 0. + +Note that you should increase job concurrencies to increase overall utilization and more effectively distribute work across multiple GPUs. Additionally, each GPU must be able to load all models. It is not possible to distribute a single model to multiple GPUs that individually have insufficient VRAM, or to delegate a specific model to one GPU. [hw-file]: https://github.com/immich-app/immich/releases/latest/download/hwaccel.ml.yml [nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html diff --git a/docs/docs/guides/remote-access.md b/docs/docs/guides/remote-access.md index 1ea068c3a0a79..6f401dfc5a5c3 100644 --- a/docs/docs/guides/remote-access.md +++ b/docs/docs/guides/remote-access.md @@ -27,7 +27,7 @@ You may use a VPN service to open an encrypted connection to your Immich instanc If you are unable to open a port on your router for Wireguard or OpenVPN to your server, [Tailscale](https://tailscale.com/) is a good option. Tailscale mediates a peer-to-peer wireguard tunnel between your server and remote device, even if one or both of them are behind a [NAT firewall](https://en.wikipedia.org/wiki/Network_address_translation). -:::tip Video toturial +:::tip Video tutorial You can learn how to set up Tailscale together with Immich with the [tutorial video](https://www.youtube.com/watch?v=Vt4PDUXB_fg) they created. ::: diff --git a/docs/docs/guides/scaling-immich.md b/docs/docs/guides/scaling-immich.md new file mode 100644 index 0000000000000..a8d916ae2a807 --- /dev/null +++ b/docs/docs/guides/scaling-immich.md @@ -0,0 +1,19 @@ +# Scaling Immich + +Immich is built with modern deployment practices in mind, and the backend is designed to be able to run multiple instances in parallel. When doing this, the only requirement you need to be aware of is that every instance needs to be connected to the shared infrastructure. That means they should all have access to the same Postgres and Redis instances, and have the same files mounted into the containers. + +Scaling can be useful for many reasons. Maybe you have a gaming PC that you want to use for transcoding and thumbnail generation, or perhaps you run a Kubernetes cluster across a handful of powerful servers that you want to make use of. + +:::info +If you only have a single machine to run Immich on, scaling to multiple containers is unlikely to provide any benefit. An Immich container will run multiple background tasks at once, and you can increase their number from the admin panel. +::: + +The details of how to scale across multiple machines will vary widely between different environments and require some knowledge to set up, and as such this guide gives no specific instructions. In some cases scaling up can be as easy as incrementing the amount of replicas on a Kubernetes deployment, in others it might need you to configure network tunnels or NFS mounts. The details are left as an exercise for the reader ;) + +## Workers + +By default, each running `immich-server` container comes with multiple internal workers. If you're scaling up only to handle more background tasks, you can choose to disable the worker responsible for the API. See [workers](../administration/jobs-workers.md) for more detail. + +## Scaling down + +In the same way you can scale up to multiple containers, you can also choose to scale down. All state is stored in Postgres, Redis, and the filesystem so there is no risk in stopping a running immich-server container, for example if you want to use your GPU to play some games. As long as there is an API worker running you will still be able to browse Immich, and jobs will wait to be processed until there is a worker available for them. diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index abbba8c6b39b5..b789d8653f168 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -20,6 +20,7 @@ The default configuration looks like this: "acceptedVideoCodecs": ["h264"], "targetAudioCodec": "aac", "acceptedAudioCodecs": ["aac", "mp3", "libopus"], + "acceptedContainers": ["mov", "ogg", "webm"], "targetResolution": "720", "maxBitrate": "0", "bframes": -1, @@ -32,7 +33,8 @@ The default configuration looks like this: "preferredHwDevice": "auto", "transcode": "required", "tonemap": "hable", - "accel": "disabled" + "accel": "disabled", + "accelDecode": false }, "job": { "backgroundTask": { @@ -60,10 +62,13 @@ The default configuration looks like this: "concurrency": 5 }, "thumbnailGeneration": { - "concurrency": 5 + "concurrency": 3 }, "videoConversion": { "concurrency": 1 + }, + "notifications": { + "concurrency": 5 } }, "logging": { @@ -78,40 +83,46 @@ The default configuration looks like this: "modelName": "ViT-B-32__openai" }, "duplicateDetection": { - "enabled": false, - "maxDistance": 0.03 + "enabled": true, + "maxDistance": 0.01 }, "facialRecognition": { "enabled": true, "modelName": "buffalo_l", "minScore": 0.7, - "maxDistance": 0.6, + "maxDistance": 0.5, "minFaces": 3 } }, "map": { "enabled": true, - "lightStyle": "", - "darkStyle": "" + "lightStyle": "https://tiles.immich.cloud/v1/style/light.json", + "darkStyle": "https://tiles.immich.cloud/v1/style/dark.json" }, "reverseGeocoding": { "enabled": true }, + "metadata": { + "faces": { + "import": false + } + }, "oauth": { - "enabled": false, - "issuerUrl": "", + "autoLaunch": false, + "autoRegister": true, + "buttonText": "Login with OAuth", "clientId": "", "clientSecret": "", + "defaultStorageQuota": 0, + "enabled": false, + "issuerUrl": "", + "mobileOverrideEnabled": false, + "mobileRedirectUri": "", "scope": "openid email profile", "signingAlgorithm": "RS256", + "profileSigningAlgorithm": "none", "storageLabelClaim": "preferred_username", - "storageQuotaClaim": "immich_quota", - "defaultStorageQuota": 0, - "buttonText": "Login with OAuth", - "autoRegister": true, - "autoLaunch": false, - "mobileOverrideEnabled": false, - "mobileRedirectUri": "" + "storageQuotaClaim": "immich_quota" }, "passwordLogin": { "enabled": true @@ -122,11 +133,16 @@ The default configuration looks like this: "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" }, "image": { - "thumbnailFormat": "webp", - "thumbnailSize": 250, - "previewFormat": "jpeg", - "previewSize": 1440, - "quality": 80, + "thumbnail": { + "format": "webp", + "size": 250, + "quality": 80 + }, + "preview": { + "format": "jpeg", + "size": 1440, + "quality": 80 + }, "colorspace": "p3", "extractEmbedded": false }, @@ -140,23 +156,35 @@ The default configuration looks like this: "theme": { "customCss": "" }, - "user": { - "deleteDelay": 7 - }, "library": { "scan": { "enabled": true, "cronExpression": "0 0 * * *" }, "watch": { - "enabled": false, - "usePolling": false, - "interval": 10000 + "enabled": false } }, "server": { "externalDomain": "", "loginPageMessage": "" + }, + "notifications": { + "smtp": { + "enabled": false, + "from": "", + "replyTo": "", + "transport": { + "ignoreCert": false, + "host": "", + "port": 587, + "username": "", + "password": "" + } + } + }, + "user": { + "deleteDelay": 7 } } ``` diff --git a/docs/docs/install/docker-compose.mdx b/docs/docs/install/docker-compose.mdx index 9ef63523a05ec..b73d51b4d240a 100644 --- a/docs/docs/install/docker-compose.mdx +++ b/docs/docs/install/docker-compose.mdx @@ -58,6 +58,7 @@ Optionally, you can enable hardware acceleration for machine learning and transc - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. - Consider changing `DB_PASSWORD` to a custom value. Postgres is not publically exposed, so this password is only used for local authentication. To avoid issues with Docker parsing this value, it is best to use only the characters `A-Za-z0-9`. +- Set your timezone by uncommenting the `TZ=` line. ### Step 3 - Start the containers @@ -80,6 +81,10 @@ The Compose file './docker-compose.yml' is invalid because: See the previous paragraph about installing from the official docker repository. ::: +:::info Health check start interval +If you get an error `can't set healthcheck.start_interval as feature require Docker Engine v25 or later`, it helps to comment out the line for `start_interval` in the `database` section of the `docker-compose.yml` file. +::: + :::tip For more information on how to use the application, please refer to the [Post Installation](/docs/install/post-install.mdx) guide. ::: diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index a0cf71e044724..b8fdf72234ed8 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -27,23 +27,14 @@ If this should not work, try running `docker compose up -d --force-recreate`. These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly. ::: -### Supported filesystems - -The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group -ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. -It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). -If this is an issue, you can change the bind mount to a Docker volume instead. - -Regardless of filesystem, it is not recommended to use a network share for your database location due to performance and possible data loss issues. - ## General | Variable | Description | Default | Containers | Workers | | :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | -| `TZ` | Timezone | | server | microservices | +| `TZ` | Timezone | \*1 | server | microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | | `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | -| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*1⚠️ | `./upload`\*2 | server | api, microservices | +| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `./upload`\*3 | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | | `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | | @@ -51,17 +42,15 @@ Regardless of filesystem, it is not recommended to use a network share for your | `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | +| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices | -\*1: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. +\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. +`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. -\*2: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. -It only need to be set if the Immich deployment method is changing. +\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. -:::tip -`TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. - -`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. -::: +\*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. +It only need to be set if the Immich deployment method is changing. ## Workers @@ -175,6 +164,7 @@ Redis (Sentinel) URL example JSON before encoding: | `MACHINE_LEARNING_ANN` | Enable ARM-NN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_ANN_FP16_TURBO` | Execute operations in FP16 precision: increasing speed, reducing precision (applies only to ARM-NN) | `False` | machine learning | | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | +| `MACHINE_LEARNING_DEVICE_IDS`\*4 | Device IDs to use in multi-GPU environments | `0` | machine learning | \*1: It is recommended to begin with this parameter when changing the concurrency levels of the machine learning service and then tune the other ones. @@ -182,6 +172,8 @@ Redis (Sentinel) URL example JSON before encoding: \*3: For scenarios like HPA in K8S. https://github.com/immich-app/immich/discussions/12064 +\*4: Using multiple GPUs requires `MACHINE_LEARNING_WORKERS` to be set greater than 1. A single device is assigned to each worker in round-robin priority. + :::info Other machine learning parameters can be tuned from the admin UI. diff --git a/docs/docs/install/post-install.mdx b/docs/docs/install/post-install.mdx index ba8aa2e9a35a6..bc1ee80b47d11 100644 --- a/docs/docs/install/post-install.mdx +++ b/docs/docs/install/post-install.mdx @@ -8,6 +8,7 @@ import StorageTemplate from '/docs/partials/_storage-template.md'; import MobileAppDownload from '/docs/partials/_mobile-app-download.md'; import MobileAppLogin from '/docs/partials/_mobile-app-login.md'; import MobileAppBackup from '/docs/partials/_mobile-app-backup.md'; +import ServerBackup from '/docs/partials/_server-backup.md'; # Post Install Steps @@ -33,6 +34,10 @@ A list of common steps to take after installing Immich include: -## Step 6 - Backup Your Library +## Step 6 - Upload Your Library + +## Step 7 - Setup Server Backups + + diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index 88d85c7bee8cc..b96705203aa8f 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -23,7 +23,33 @@ Immich requires the command `docker compose` - the similarly named `docker-compo - **RAM**: Minimum 4GB, recommended 6GB. - **CPU**: Minimum 2 cores, recommended 4 cores. - **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions. - - This can present an issue for Windows users. See [here](/docs/install/environment-variables#supported-filesystems) - for more details and alternatives. + - This can present an issue for Windows users. See below for details and an alternative setup. - The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average. - - Network shares are supported for the storage of image and video assets only. + - Network shares are supported for the storage of image and video assets only. It is not recommended to use a network share for your database location due to performance and possible data loss issues. + +### Special requirements for Windows users + +
+Database storage on Windows systems + +The Immich Postgres database (`DB_DATA_LOCATION`) must be located on a filesystem that supports user/group +ownership and permissions (EXT2/3/4, ZFS, APFS, BTRFS, XFS, etc.). It will not work on any filesystem formatted in NTFS or ex/FAT/32. +It will not work in WSL (Windows Subsystem for Linux) when using a mounted host directory (commonly under `/mnt`). +If this is an issue, you can change the bind mount to a Docker volume instead as follows: + +Make the following change to `.env`: + +```diff +- DB_DATA_LOCATION=./postgres ++ DB_DATA_LOCATION=pgdata +``` + +Add the following line to the bottom of `docker-compose.yml`: + +```diff +volumes: + model-cache: ++ pgdata: +``` + +
diff --git a/docs/docs/install/truenas.md b/docs/docs/install/truenas.md index 271cd52cabc95..ffb559ed1216b 100644 --- a/docs/docs/install/truenas.md +++ b/docs/docs/install/truenas.md @@ -30,6 +30,8 @@ You can organize these as one parent with seven child datasets, for example `mnt :::info Permissions The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions. + +The **library** dataset must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. ::: ## Installing the Immich Application diff --git a/docs/docs/partials/_server-backup.md b/docs/docs/partials/_server-backup.md new file mode 100644 index 0000000000000..7aab90a753c24 --- /dev/null +++ b/docs/docs/partials/_server-backup.md @@ -0,0 +1,2 @@ +Now that you have imported some pictures, you should setup server backups to preserve your memories. +You can do so by following our [backup guide](/docs/administration/backup-and-restore.md). diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index a94a54b60c81a..16d654b46bc58 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -72,14 +72,9 @@ const config = { themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ ({ - colorMode: { - defaultMode: 'dark', - }, announcementBar: { id: 'site_announcement_immich', content: `⚠️ The project is under very active development. Expect bugs and changes. Do not use it as the only way to store your photos and videos!`, - backgroundColor: '#593f00', - textColor: '#ffefc9', isCloseable: false, }, docs: { @@ -201,7 +196,7 @@ const config = { darkTheme: prism.themes.dracula, additionalLanguages: ['sql', 'diff', 'bash', 'powershell', 'nginx'], }, - image: 'overview/img/feature-panel.png', + image: 'img/feature-panel.png', }), }; diff --git a/docs/package-lock.json b/docs/package-lock.json index 05417ce1275a7..909263da859a4 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -6068,9 +6068,10 @@ } }, "node_modules/docusaurus-lunr-search": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.4.0.tgz", - "integrity": "sha512-GfllnNXCLgTSPH9TAKWmbn8VMfwpdOAZ1xl3T2GgX8Pm26qSDLfrrdVwjguaLfMJfzciFL97RKrAJlgrFM48yw==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.5.0.tgz", + "integrity": "sha512-k3zN4jYMi/prWInJILGKOxE+BVcgYinwj9+gcECsYm52tS+4ZKzXQzbPnVJAEXmvKOfFMcDFvS3MSmm6cEaxIQ==", + "license": "MIT", "dependencies": { "autocomplete.js": "^0.37.0", "clsx": "^1.2.1", @@ -6097,14 +6098,16 @@ } }, "node_modules/docusaurus-lunr-search/node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" }, "node_modules/docusaurus-lunr-search/node_modules/bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -6114,6 +6117,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", "engines": { "node": ">=6" } @@ -6122,6 +6126,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", "engines": { "node": ">=8" } @@ -6130,6 +6135,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -6139,6 +6145,7 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "license": "MIT", "dependencies": { "bail": "^1.0.0", "extend": "^3.0.0", @@ -6156,6 +6163,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "license": "MIT", "dependencies": { "@types/unist": "^2.0.2" }, @@ -6168,6 +6176,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", "is-buffer": "^2.0.0", @@ -6183,6 +6192,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "license": "MIT", "dependencies": { "@types/unist": "^2.0.0", "unist-util-stringify-position": "^2.0.0" @@ -12705,9 +12715,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "license": "ISC" }, "node_modules/picomatch": { @@ -12819,9 +12829,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -12839,8 +12849,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -15660,9 +15670,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -16081,9 +16092,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", - "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", + "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -16443,9 +16454,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/docs/package.json b/docs/package.json index cdcdf53446884..b7fa64097d6e0 100644 --- a/docs/package.json +++ b/docs/package.json @@ -56,6 +56,6 @@ "node": ">=20" }, "volta": { - "node": "20.17.0" + "node": "20.18.0" } } diff --git a/docs/src/components/community-guides.tsx b/docs/src/components/community-guides.tsx index 6982853fade77..7f4206c97baf1 100644 --- a/docs/src/components/community-guides.tsx +++ b/docs/src/components/community-guides.tsx @@ -52,20 +52,20 @@ const guides: CommunityGuidesProps[] = [ function CommunityGuide({ title, description, url }: CommunityGuidesProps): JSX.Element { return ( -
+
-

+

{title}

{description}

-

+

{url}

View Guide diff --git a/docs/src/components/community-projects.tsx b/docs/src/components/community-projects.tsx index d8273c67c2179..3a034e3a04cfd 100644 --- a/docs/src/components/community-projects.tsx +++ b/docs/src/components/community-projects.tsx @@ -87,23 +87,23 @@ const projects: CommunityProjectProps[] = [ function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { return ( -
+
-

+

{title}

{description}

-

+

{url}

- View Project + View Link
diff --git a/docs/src/components/svg-paths.ts b/docs/src/components/svg-paths.ts new file mode 100644 index 0000000000000..112ed1d70fa85 --- /dev/null +++ b/docs/src/components/svg-paths.ts @@ -0,0 +1,2 @@ +export const discordPath = + 'M 9.1367188 3.8691406 C 9.1217187 3.8691406 9.1067969 3.8700938 9.0917969 3.8710938 C 8.9647969 3.8810937 5.9534375 4.1403594 4.0234375 5.6933594 C 3.0154375 6.6253594 1 12.073203 1 16.783203 C 1 16.866203 1.0215 16.946531 1.0625 17.019531 C 2.4535 19.462531 6.2473281 20.102859 7.1113281 20.130859 L 7.1269531 20.130859 C 7.2799531 20.130859 7.4236719 20.057594 7.5136719 19.933594 L 8.3886719 18.732422 C 6.0296719 18.122422 4.8248594 17.086391 4.7558594 17.025391 C 4.5578594 16.850391 4.5378906 16.549563 4.7128906 16.351562 C 4.8068906 16.244563 4.9383125 16.189453 5.0703125 16.189453 C 5.1823125 16.189453 5.2957188 16.228594 5.3867188 16.308594 C 5.4157187 16.334594 7.6340469 18.216797 11.998047 18.216797 C 16.370047 18.216797 18.589328 16.325641 18.611328 16.306641 C 18.702328 16.227641 18.815734 16.189453 18.927734 16.189453 C 19.059734 16.189453 19.190156 16.243562 19.285156 16.351562 C 19.459156 16.549563 19.441141 16.851391 19.244141 17.025391 C 19.174141 17.087391 17.968375 18.120469 15.609375 18.730469 L 16.484375 19.933594 C 16.574375 20.057594 16.718094 20.130859 16.871094 20.130859 L 16.886719 20.130859 C 17.751719 20.103859 21.5465 19.463531 22.9375 17.019531 C 22.9785 16.947531 23 16.866203 23 16.783203 C 23 12.073203 20.984172 6.624875 19.951172 5.671875 C 18.047172 4.140875 15.036203 3.8820937 14.908203 3.8710938 C 14.895203 3.8700938 14.880188 3.8691406 14.867188 3.8691406 C 14.681188 3.8691406 14.510594 3.9793906 14.433594 4.1503906 C 14.427594 4.1623906 14.362062 4.3138281 14.289062 4.5488281 C 15.548063 4.7608281 17.094141 5.1895937 18.494141 6.0585938 C 18.718141 6.1975938 18.787437 6.4917969 18.648438 6.7167969 C 18.558438 6.8627969 18.402188 6.9433594 18.242188 6.9433594 C 18.156188 6.9433594 18.069234 6.9200937 17.990234 6.8710938 C 15.584234 5.3800938 12.578 5.3046875 12 5.3046875 C 11.422 5.3046875 8.4157187 5.3810469 6.0117188 6.8730469 C 5.9327188 6.9210469 5.8457656 6.9433594 5.7597656 6.9433594 C 5.5997656 6.9433594 5.4425625 6.86475 5.3515625 6.71875 C 5.2115625 6.49375 5.2818594 6.1985938 5.5058594 6.0585938 C 6.9058594 5.1905937 8.4528906 4.7627812 9.7128906 4.5507812 C 9.6388906 4.3147813 9.5714062 4.1643437 9.5664062 4.1523438 C 9.4894063 3.9813438 9.3217188 3.8691406 9.1367188 3.8691406 z M 12 7.3046875 C 12.296 7.3046875 14.950594 7.3403125 16.933594 8.5703125 C 17.326594 8.8143125 17.777234 8.9453125 18.240234 8.9453125 C 18.633234 8.9453125 19.010656 8.8555 19.347656 8.6875 C 19.964656 10.2405 20.690828 12.686219 20.923828 15.199219 C 20.883828 15.143219 20.840922 15.089109 20.794922 15.037109 C 20.324922 14.498109 19.644687 14.191406 18.929688 14.191406 C 18.332687 14.191406 17.754078 14.405437 17.330078 14.773438 C 17.257078 14.832437 15.505 16.21875 12 16.21875 C 8.496 16.21875 6.7450313 14.834687 6.7070312 14.804688 C 6.2540312 14.407687 5.6742656 14.189453 5.0722656 14.189453 C 4.3612656 14.189453 3.6838438 14.494391 3.2148438 15.025391 C 3.1658438 15.080391 3.1201719 15.138266 3.0761719 15.197266 C 3.3091719 12.686266 4.0344375 10.235594 4.6484375 8.6835938 C 4.9864375 8.8525938 5.3657656 8.9433594 5.7597656 8.9433594 C 6.2217656 8.9433594 6.6724531 8.8143125 7.0644531 8.5703125 C 9.0494531 7.3393125 11.704 7.3046875 12 7.3046875 z M 8.890625 10.044922 C 7.966625 10.044922 7.2167969 10.901031 7.2167969 11.957031 C 7.2167969 13.013031 7.965625 13.869141 8.890625 13.869141 C 9.815625 13.869141 10.564453 13.013031 10.564453 11.957031 C 10.564453 10.900031 9.815625 10.044922 8.890625 10.044922 z M 15.109375 10.044922 C 14.185375 10.044922 13.435547 10.901031 13.435547 11.957031 C 13.435547 13.013031 14.184375 13.869141 15.109375 13.869141 C 16.034375 13.869141 16.783203 13.013031 16.783203 11.957031 C 16.783203 10.900031 16.033375 10.044922 15.109375 10.044922 z'; diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 5ee7bf7393e57..f693ce701b3ab 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -7,11 +7,12 @@ @tailwind components; @tailwind utilities; -@import url('https://fonts.googleapis.com/css2?family=Overpass:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400;1,500;1,600;1,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); html, button { - font-family: 'Overpass', sans-serif; + font-family: 'Be Vietnam Pro', sans-serif; + font-optical-sizing: auto; } img { @@ -27,7 +28,6 @@ img { --ifm-color-primary-light: #4250af; --ifm-color-primary-lighter: #4250af; --ifm-color-primary-lightest: #4250af; - --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); } @@ -40,10 +40,28 @@ img { --ifm-color-primary-light: #d5e4fc; --ifm-color-primary-lighter: #e9f1fe; --ifm-color-primary-lightest: #ffffff; - --ifm-background-color: #000000; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); + --ifm-background-color: #000000; } div[class^='announcementBar_'] { min-height: 2rem; + background-color: #2b3336; + color: white; +} + +.menu__link { + padding: 10px; + padding-left: 16px; + border-radius: 10px; + font-size: 15px; +} + +.menu__list-item-collapsible { + border-radius: 10px; + font-size: 15px; +} + +code { + font-weight: 600; } diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx index 638868bec5324..1e5c724d16678 100644 --- a/docs/src/pages/cursed-knowledge.tsx +++ b/docs/src/pages/cursed-knowledge.tsx @@ -1,22 +1,39 @@ import { + mdiBug, mdiCalendarToday, mdiCrosshairsOff, + mdiDatabase, mdiLeadPencil, mdiLockOff, mdiLockOutline, + mdiMicrosoftWindows, + mdiSecurity, mdiSpeedometerSlow, + mdiTrashCan, mdiWeb, mdiWrap, } from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; -import { Item as TimelineItem, Timeline } from '../components/timeline'; +import { Timeline, Item as TimelineItem } from '../components/timeline'; const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language); type Item = Omit & { date: Date }; const items: Item[] = [ + { + icon: mdiMicrosoftWindows, + iconColor: '#357EC7', + title: 'Hidden files in Windows are cursed', + description: + 'Hidden files in Windows cannot be opened with the "w" flag. That, combined with SMB option "hide dot files" leads to a lot of confusion.', + link: { + url: 'https://github.com/immich-app/immich/pull/12812', + text: '#12812', + }, + date: new Date(2024, 8, 20), + }, { icon: mdiWrap, iconColor: 'gray', @@ -96,6 +113,51 @@ const items: Item[] = [ link: { url: 'https://github.com/immich-app/immich/pull/6787', text: '#6787' }, date: new Date(2024, 0, 31), }, + { + icon: mdiBug, + iconColor: 'green', + title: 'ESM imports are cursed', + description: + 'Prior to Node.js v20.8 using --experimental-vm-modules in a CommonJS project that imported an ES module that imported a CommonJS modules would create a segfault and crash Node.js', + link: { + url: 'https://github.com/immich-app/immich/pull/6719', + text: '#6179', + }, + date: new Date(2024, 0, 9), + }, + { + icon: mdiDatabase, + iconColor: 'gray', + title: 'PostgreSQL parameters are cursed', + description: `PostgresSQL has a limit of ${Number(65535).toLocaleString()} parameters, so bulk inserts can fail with large datasets.`, + link: { + url: 'https://github.com/immich-app/immich/pull/6034', + text: '#6034', + }, + date: new Date(2023, 11, 28), + }, + { + icon: mdiSecurity, + iconColor: 'gold', + title: 'Secure contexts are cursed', + description: `Some web features like the clipboard API only work in "secure contexts" (ie. https or localhost)`, + link: { + url: 'https://github.com/immich-app/immich/issues/2981', + text: '#2981', + }, + date: new Date(2023, 5, 26), + }, + { + icon: mdiTrashCan, + iconColor: 'gray', + title: 'TypeORM deletes are cursed', + description: `The remove implementation in TypeORM mutates the input, deleting the id property from the original object.`, + link: { + url: 'https://github.com/typeorm/typeorm/issues/7024#issuecomment-948519328', + text: 'typeorm#6034', + }, + date: new Date(2023, 1, 23), + }, ]; export default function CursedKnowledgePage(): JSX.Element { diff --git a/docs/src/pages/index.tsx b/docs/src/pages/index.tsx index a375efb8a8590..a5dbc7aa98c6b 100644 --- a/docs/src/pages/index.tsx +++ b/docs/src/pages/index.tsx @@ -2,46 +2,82 @@ import React from 'react'; import Link from '@docusaurus/Link'; import Layout from '@theme/Layout'; import { useColorMode } from '@docusaurus/theme-common'; +import { discordPath } from '@site/src/components/svg-paths'; +import Icon from '@mdi/react'; function HomepageHeader() { const { isDarkTheme } = useColorMode(); return (
-
+
+ Immich logo +
+
+
Immich logo -
-

- Self-hosted photo and - video management solution +

+

+ Self-hosted{' '} + + photo and + video management{' '} + + solution +

+ +

+ Easily back up, organize, and manage your photos on your own server. Immich helps you + browse, search and organize your photos and videos with ease, without + sacrificing your privacy.

-
+ +
Get started - Demo portal + Demo +
- - Discord - +
+ + Join our Discord +
+ screenshots + +
+
+
+ + Immich logo + +
+

Download mobile app

+

+ Download Immich app and start backing up your photos and videos securely to your own server +

- screenshots + + app qr code
); @@ -61,13 +104,9 @@ function HomepageHeader() { export default function Home(): JSX.Element { return ( - + -
+

This project is available under GNU AGPL v3 license.

Privacy should not be a luxury

diff --git a/docs/src/pages/roadmap.tsx b/docs/src/pages/roadmap.tsx index b7c3c8af20b6c..1f07e45122749 100644 --- a/docs/src/pages/roadmap.tsx +++ b/docs/src/pages/roadmap.tsx @@ -70,6 +70,8 @@ import { mdiThemeLightDark, mdiTrashCanOutline, mdiVectorCombine, + mdiFolderSync, + mdiFaceRecognition, mdiVideo, mdiWeb, } from '@mdi/js'; @@ -78,6 +80,7 @@ import React from 'react'; import { Item, Timeline } from '../components/timeline'; const releases = { + 'v1.114.0': new Date(2024, 8, 6), 'v1.113.0': new Date(2024, 7, 30), 'v1.112.0': new Date(2024, 7, 14), 'v1.111.0': new Date(2024, 6, 26), @@ -231,6 +234,12 @@ const roadmap: Item[] = [ ]; const milestones: Item[] = [ + withRelease({ + icon: mdiFaceRecognition, + title: 'Metadata Face Import', + description: 'Read face metadata in Digikam format during import', + release: 'v1.114.0', + }), withRelease({ icon: mdiTagMultiple, iconColor: 'orange', @@ -238,11 +247,18 @@ const milestones: Item[] = [ description: 'Tag your photos and videos', release: 'v1.113.0', }), + withRelease({ + icon: mdiFolderSync, + iconColor: 'green', + title: 'Album sync (mobile)', + description: 'Sync or mirror an album from your phone to the Immich server', + release: 'v1.113.0', + }), withRelease({ icon: mdiFolderMultiple, iconColor: 'brown', title: 'Folders', - description: 'View your photos and videos in folders', + description: 'Browse your photos and videos in their folder structure', release: 'v1.113.0', }), withRelease({ diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index c16413f4c5f49..d800c64d51df7 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,24 @@ [ + { + "label": "v1.117.0", + "url": "https://v1.117.0.archive.immich.app" + }, + { + "label": "v1.116.2", + "url": "https://v1.116.2.archive.immich.app" + }, + { + "label": "v1.116.1", + "url": "https://v1.116.1.archive.immich.app" + }, + { + "label": "v1.116.0", + "url": "https://v1.116.0.archive.immich.app" + }, + { + "label": "v1.115.0", + "url": "https://v1.115.0.archive.immich.app" + }, { "label": "v1.114.0", "url": "https://v1.114.0.archive.immich.app" diff --git a/docs/static/img/app-qr-code-dark.svg b/docs/static/img/app-qr-code-dark.svg new file mode 100644 index 0000000000000..c2d593ea2a4df --- /dev/null +++ b/docs/static/img/app-qr-code-dark.svg @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/static/img/app-qr-code-light.svg b/docs/static/img/app-qr-code-light.svg new file mode 100644 index 0000000000000..d5d225201e669 --- /dev/null +++ b/docs/static/img/app-qr-code-light.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/docs/static/img/feature-panel.png b/docs/static/img/feature-panel.png new file mode 100644 index 0000000000000..8c39fe0d40fea Binary files /dev/null and b/docs/static/img/feature-panel.png differ diff --git a/docs/static/img/immich-screenshots.png b/docs/static/img/immich-screenshots.png deleted file mode 100644 index 6123279f2d652..0000000000000 Binary files a/docs/static/img/immich-screenshots.png and /dev/null differ diff --git a/docs/static/img/immich-screenshots.webp b/docs/static/img/immich-screenshots.webp deleted file mode 100644 index 62cc036797179..0000000000000 Binary files a/docs/static/img/immich-screenshots.webp and /dev/null differ diff --git a/docs/static/img/logomark-dark.svg b/docs/static/img/logomark-dark.svg new file mode 100644 index 0000000000000..51f92109d4171 --- /dev/null +++ b/docs/static/img/logomark-dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/static/img/logomark-light.svg b/docs/static/img/logomark-light.svg new file mode 100644 index 0000000000000..497fbdcf14902 --- /dev/null +++ b/docs/static/img/logomark-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/static/img/screenshot-dark.webp b/docs/static/img/screenshot-dark.webp new file mode 100644 index 0000000000000..a6a7d0e1d6033 Binary files /dev/null and b/docs/static/img/screenshot-dark.webp differ diff --git a/docs/static/img/screenshot-light.webp b/docs/static/img/screenshot-light.webp new file mode 100644 index 0000000000000..0d88697f478a7 Binary files /dev/null and b/docs/static/img/screenshot-light.webp differ diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js index 1ef26facbb621..98f69bcd59b7a 100644 --- a/docs/tailwind.config.js +++ b/docs/tailwind.config.js @@ -11,13 +11,13 @@ module.exports = { colors: { // Light Theme 'immich-primary': '#4250af', - 'immich-bg': 'white', + 'immich-bg': '#f9f8fb', 'immich-fg': 'black', 'immich-gray': '#F6F6F4', // Dark Theme 'immich-dark-primary': '#adcbfa', - 'immich-dark-bg': 'black', + 'immich-dark-bg': '#070a14', 'immich-dark-fg': '#e5e7eb', 'immich-dark-gray': '#212121', }, diff --git a/e2e/.nvmrc b/e2e/.nvmrc index 3516580bbbc04..2a393af592b8c 100644 --- a/e2e/.nvmrc +++ b/e2e/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index dd7632b212fe3..00be9162a2585 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -22,7 +22,6 @@ services: - IMMICH_METRICS=true - IMMICH_ENV=testing volumes: - - upload:/usr/src/app/upload - ./test-assets:/test-assets extra_hosts: - 'auth-server:host-gateway' @@ -33,7 +32,7 @@ services: - 2285:3001 redis: - image: redis:6.2-alpine@sha256:d72905e7e3d53aa237968c0724f7bb22165c6a2068a06e66a361e4add0df75fd + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 @@ -44,7 +43,3 @@ services: POSTGRES_DB: immich ports: - 5435:5432 - -volumes: - model-cache: - upload: diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 97e396c09f1b0..9db2913c79eb2 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.114.0", + "version": "1.117.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.114.0", + "version": "1.117.0", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -15,7 +15,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -27,7 +27,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", - "exiftool-vendored": "^28.0.0", + "exiftool-vendored": "^28.3.1", "globals": "^15.9.0", "jose": "^5.6.3", "luxon": "^3.4.4", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.18", + "version": "2.2.23", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -64,7 +64,7 @@ "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", "@types/mock-fs": "^4.13.1", - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^2.0.5", @@ -92,14 +92,14 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.117.0", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "typescript": "^5.3.3" } }, @@ -361,6 +361,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -377,6 +378,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -393,6 +395,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -409,6 +412,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -425,6 +429,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -441,6 +446,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -457,6 +463,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -473,6 +480,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -489,6 +497,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -505,6 +514,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -521,6 +531,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -537,6 +548,7 @@ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -553,6 +565,7 @@ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -569,6 +582,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -585,6 +599,7 @@ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -601,6 +616,7 @@ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -617,6 +633,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -633,6 +650,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -649,6 +667,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -665,6 +684,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -681,6 +701,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -697,6 +718,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -713,6 +735,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -761,6 +784,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", @@ -799,9 +832,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", - "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", "dev": true, "license": "MIT", "engines": { @@ -818,6 +851,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1085,10 +1131,11 @@ } }, "node_modules/@photostructure/tz-lookup": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz", - "integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA==", - "dev": true + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.0.0.tgz", + "integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw==", + "dev": true, + "license": "CC0-1.0" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -1113,13 +1160,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.1.tgz", - "integrity": "sha512-Fq6SwLujA/DOIvNC2EL/SojJnkKf/rAwJ//APpJJHRyMi1PdKrY3Az+4XNQ51N4RTbItbIByQ0jgd1tayq1aeA==", + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.2.tgz", + "integrity": "sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.46.1" + "playwright": "1.47.2" }, "bin": { "playwright": "cli.js" @@ -1129,208 +1176,224 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.19.1.tgz", - "integrity": "sha512-XzqSg714++M+FXhHfXpS1tDnNZNpgxxuGZWlRG/jSj+VEPmZ0yg6jV4E0AL3uyBKxO8mO3xtOsP5mQ+XLfrlww==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.19.1.tgz", - "integrity": "sha512-thFUbkHteM20BGShD6P08aungq4irbIZKUNbG70LN8RkO7YztcGPiKTTGZS7Kw+x5h8hOXs0i4OaHwFxlpQN6A==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.19.1.tgz", - "integrity": "sha512-8o6eqeFZzVLia2hKPUZk4jdE3zW7LCcZr+MD18tXkgBBid3lssGVAYuox8x6YHoEPDdDa9ixTaStcmx88lio5Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.19.1.tgz", - "integrity": "sha512-4T42heKsnbjkn7ovYiAdDVRRWZLU9Kmhdt6HafZxFcUdpjlBlxj4wDrt1yFWLk7G4+E+8p2C9tcmSu0KA6auGA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.19.1.tgz", - "integrity": "sha512-MXg1xp+e5GhZ3Vit1gGEyoC+dyQUBy2JgVQ+3hUrD9wZMkUw/ywgkpK7oZgnB6kPpGrxJ41clkPPnsknuD6M2Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.19.1.tgz", - "integrity": "sha512-DZNLwIY4ftPSRVkJEaxYkq7u2zel7aah57HESuNkUnz+3bZHxwkCUkrfS2IWC1sxK6F2QNIR0Qr/YXw7nkF3Pw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.19.1.tgz", - "integrity": "sha512-C7evongnjyxdngSDRRSQv5GvyfISizgtk9RM+z2biV5kY6S/NF/wta7K+DanmktC5DkuaJQgoKGf7KUDmA7RUw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.19.1.tgz", - "integrity": "sha512-89tFWqxfxLLHkAthAcrTs9etAoBFRduNfWdl2xUs/yLV+7XDrJ5yuXMHptNqf1Zw0UCA3cAutkAiAokYCkaPtw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.19.1.tgz", - "integrity": "sha512-PromGeV50sq+YfaisG8W3fd+Cl6mnOOiNv2qKKqKCpiiEke2KiKVyDqG/Mb9GWKbYMHj5a01fq/qlUR28PFhCQ==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.19.1.tgz", - "integrity": "sha512-/1BmHYh+iz0cNCP0oHCuF8CSiNj0JOGf0jRlSo3L/FAyZyG2rGBuKpkZVH9YF+x58r1jgWxvm1aRg3DHrLDt6A==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.19.1.tgz", - "integrity": "sha512-0cYP5rGkQWRZKy9/HtsWVStLXzCF3cCBTRI+qRL8Z+wkYlqN7zrSYm6FuY5Kd5ysS5aH0q5lVgb/WbG4jqXN1Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.19.1.tgz", - "integrity": "sha512-XUXeI9eM8rMP8aGvii/aOOiMvTs7xlCosq9xCjcqI9+5hBxtjDpD+7Abm1ZhVIFE1J2h2VIg0t2DX/gjespC2Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.19.1.tgz", - "integrity": "sha512-V7cBw/cKXMfEVhpSvVZhC+iGifD6U1zJ4tbibjjN+Xi3blSXaj/rJynAkCFFQfoG6VZrAiP7uGVzL440Q6Me2Q==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.19.1.tgz", - "integrity": "sha512-88brja2vldW/76jWATlBqHEoGjJLRnP0WOEKAUbMcXaAZnemNhlAHSyj4jIwMoP2T750LE9lblvD4e2jXleZsA==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.19.1.tgz", - "integrity": "sha512-LdxxcqRVSXi6k6JUrTah1rHuaupoeuiv38du8Mt4r4IPer3kwlTo+RuvfE8KzZ/tL6BhaPlzJ3835i6CxrFIRQ==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.19.1.tgz", - "integrity": "sha512-2bIrL28PcK3YCqD9anGxDxamxdiJAxA+l7fWIwM5o8UqNy1t3d1NdAweO2XhA0KTDJ5aH1FsuiT5+7VhtHliXg==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1419,10 +1482,11 @@ } }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/express": { "version": "4.17.21", @@ -1466,6 +1530,13 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", @@ -1516,9 +1587,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "dev": true, "license": "MIT", "dependencies": { @@ -1543,9 +1614,9 @@ } }, "node_modules/@types/pg": { - "version": "8.11.8", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.8.tgz", - "integrity": "sha512-IqpCf8/569txXN/HoP5i1LjXfKZWL76Yr2R77xgeIICUbAYHeoaEZFhYHo2uDftecLWrTJUq63JvQu8q3lnDyA==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dev": true, "license": "MIT", "dependencies": { @@ -1680,17 +1751,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", - "integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/type-utils": "8.3.0", - "@typescript-eslint/utils": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -1714,16 +1785,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", - "integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4" }, "engines": { @@ -1743,14 +1814,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", - "integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1761,14 +1832,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", - "integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/utils": "8.3.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1786,9 +1857,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", - "integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", "dev": true, "license": "MIT", "engines": { @@ -1800,14 +1871,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", - "integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1855,16 +1926,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", - "integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0" + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1878,13 +1949,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", - "integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1896,19 +1967,20 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.2.tgz", + "integrity": "sha512-b7kHrFrs2urS0cOk5N10lttI8UdJ/yP3nB4JYTREvR5o18cR99yPpK4gK8oQgI42BVv0ILWYUSYB7AXkAUDc0g==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -1918,17 +1990,24 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.2.tgz", + "integrity": "sha512-FEgtlN8mIUSEAAnlvn7mP8vzaWhEaAEvhSXCqrsijM7K6QqjB11qoRZYEd4AKSCDz8p0/+yH5LzhZ47qt+EyPg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1936,11 +2015,40 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.2.tgz", + "integrity": "sha512-ExElkCGMS13JAJy+812fw1aCv2QO/LBK6CyO4WOPAzLTmve50gydOlWhgdBJPx2ztbADUq3JVI0C5U+bShaeEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.2", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.2.tgz", + "integrity": "sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==", "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^1.2.0" }, @@ -1949,12 +2057,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.2.tgz", + "integrity": "sha512-UCsPtvluHO3u7jdoONGjOSil+uON5SSvU9buQh3lP7GgUXHp78guN1wRmZDX4wGK6J10f9NUtP6pO+SFquoMlw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -1962,13 +2071,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.2.tgz", + "integrity": "sha512-xtAeNsZ++aRIYIUsek7VHzry/9AcxeULlegBvsdLncLmNCR6tR8SRjn8BbDP4naxtccvzTqZ+L1ltZlRCfBZFA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.2", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -1976,10 +2086,11 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.2.tgz", + "integrity": "sha512-GSUi5zoy+abNRJwmFhBDC0yRuVUn8WMlQscvnbbXdKLXX9dE59YbfwXxuJ/mth6eeqIzofU8BB5XDo/Ns/qK2A==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^3.0.0" }, @@ -1988,13 +2099,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.2.tgz", + "integrity": "sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2129,6 +2240,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -2235,6 +2347,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2344,6 +2457,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -2391,6 +2505,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" } @@ -2578,12 +2693,13 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -2626,6 +2742,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2755,16 +2872,17 @@ } }, "node_modules/engine.io-client": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", - "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz", + "integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==", "dev": true, + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", - "xmlhttprequest-ssl": "~2.0.0" + "xmlhttprequest-ssl": "~2.1.1" } }, "node_modules/engine.io-parser": { @@ -2812,6 +2930,7 @@ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -2872,20 +2991,24 @@ } }, "node_modules/eslint": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz", - "integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.9.1", + "@eslint/js": "9.11.1", + "@eslint/plugin-kit": "^0.2.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -2905,7 +3028,6 @@ "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", @@ -3038,9 +3160,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3119,6 +3241,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -3144,51 +3267,28 @@ "url": "https://github.com/eta-dev/eta?sponsor=1" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/exiftool-vendored": { - "version": "28.2.1", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", - "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", + "version": "28.3.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.3.1.tgz", + "integrity": "sha512-S2LNaGNu4wBv6q0f/lvst+6DhQrYgc27oDsTgRvx8dGK/5Z1MK4PyMfKCb5GCeCr/nSTGsRnoJlxxRhO1YkBsA==", "dev": true, "license": "MIT", "dependencies": { - "@photostructure/tz-lookup": "^10.0.0", + "@photostructure/tz-lookup": "^11.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", "luxon": "^3.5.0" }, "optionalDependencies": { - "exiftool-vendored.exe": "12.91.0", - "exiftool-vendored.pl": "12.91.0" + "exiftool-vendored.exe": "12.96.0", + "exiftool-vendored.pl": "12.96.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.91.0.tgz", - "integrity": "sha512-nxcoGBaJL/D+Wb0jVe8qwyV8QZpRcCzU0aCKhG0S1XNGWGjJJJ4QV851aobcfDwI4NluFOdqkjTSf32pVijvHg==", + "version": "12.96.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.96.0.tgz", + "integrity": "sha512-pKPN9F/Evw2yyO5/+ml3spbXIqejzOxyF7jEnj8tLU2JPSmIlziPUZ75XIhcPbilX86jVKmuiso7FUDicOg8pQ==", "dev": true, "license": "MIT", "optional": true, @@ -3197,9 +3297,9 @@ ] }, "node_modules/exiftool-vendored.pl": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.91.0.tgz", - "integrity": "sha512-GZMy9+Jiv8/C7R4uYe1kWtXsAaJdgVezTwYa+wDeoqvReHiX2t5uzkCrzWdjo4LGl5mPQkyKhN7/uPLYk5Ak6w==", + "version": "12.96.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.96.0.tgz", + "integrity": "sha512-v4nGnovAMBsTfOWhwAcOiRiq/8kuJOo3GUMHNpug7Mr4jLz3tmWEo7DdNyOYmpcvWbA6smOTG0SmwsrY8fsW+A==", "dev": true, "license": "MIT", "optional": true, @@ -3480,15 +3580,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -3508,18 +3599,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3553,9 +3632,9 @@ } }, "node_modules/globals": { - "version": "15.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", - "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "version": "15.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.10.0.tgz", + "integrity": "sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ==", "dev": true, "license": "MIT", "engines": { @@ -3835,15 +3914,6 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4012,18 +4082,6 @@ "node": ">=8" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4096,9 +4154,9 @@ } }, "node_modules/jose": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.8.0.tgz", - "integrity": "sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.3.tgz", + "integrity": "sha512-egLIoYSpcd+QUF+UHgobt5YzI2Pkw/H39ou9suW687MY6PmCwPmkNV/4TNjn1p2tX5xO3j0d0sq5hiYE24bSlg==", "dev": true, "license": "MIT", "funding": { @@ -4302,13 +4360,11 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "3.0.0", @@ -4382,12 +4438,6 @@ "node": ">= 0.6" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4454,18 +4504,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", @@ -4546,10 +4584,11 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.7", @@ -4562,6 +4601,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -4664,33 +4704,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -4808,21 +4821,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/only": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", @@ -5001,36 +4999,38 @@ } }, "node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.16" } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dev": true, "license": "MIT", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -5057,10 +5057,11 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==", - "dev": true + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dev": true, + "license": "MIT" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -5081,19 +5082,21 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "dev": true, + "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==", - "dev": true + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "dev": true, + "license": "MIT" }, "node_modules/pg-types": { "version": "2.2.0", @@ -5121,10 +5124,11 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -5140,13 +5144,13 @@ } }, "node_modules/playwright": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.1.tgz", - "integrity": "sha512-oPcr1yqoXLCkgKtD5eNUPLiN40rYEM39odNpIb6VE6S7/15gJmA1NzVv6zJYusV0e7tzvkU/utBFNa/Kpxmwng==", + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.2.tgz", + "integrity": "sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.46.1" + "playwright-core": "1.47.2" }, "bin": { "playwright": "cli.js" @@ -5159,9 +5163,9 @@ } }, "node_modules/playwright-core": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.1.tgz", - "integrity": "sha512-h9LqIQaAv+CYvWzsZ+h3RsrqCStkBHlgo6/TJlFst3cOTlLghBQlJwPOZKQJTKNaD3QIB7aAVQ+gfWbN3NXB7A==", + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.2.tgz", + "integrity": "sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5190,9 +5194,9 @@ } }, "node_modules/postcss": { - "version": "8.4.40", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", - "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -5208,10 +5212,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5300,21 +5305,17 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "license": "MIT", "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } @@ -5610,12 +5611,13 @@ } }, "node_modules/rollup": { - "version": "4.19.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.19.1.tgz", - "integrity": "sha512-K5vziVlg7hTpYfFBI+91zHBEMo6jafYXpkMlqZjg7/zhIG9iHqazBf4xz9AVdjS9BruRn280ROqLI7G3OFRIlw==", + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -5625,22 +5627,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.19.1", - "@rollup/rollup-android-arm64": "4.19.1", - "@rollup/rollup-darwin-arm64": "4.19.1", - "@rollup/rollup-darwin-x64": "4.19.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.19.1", - "@rollup/rollup-linux-arm-musleabihf": "4.19.1", - "@rollup/rollup-linux-arm64-gnu": "4.19.1", - "@rollup/rollup-linux-arm64-musl": "4.19.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.19.1", - "@rollup/rollup-linux-riscv64-gnu": "4.19.1", - "@rollup/rollup-linux-s390x-gnu": "4.19.1", - "@rollup/rollup-linux-x64-gnu": "4.19.1", - "@rollup/rollup-linux-x64-musl": "4.19.1", - "@rollup/rollup-win32-arm64-msvc": "4.19.1", - "@rollup/rollup-win32-ia32-msvc": "4.19.1", - "@rollup/rollup-win32-x64-msvc": "4.19.1", + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", "fsevents": "~2.3.2" } }, @@ -5792,14 +5794,15 @@ } }, "node_modules/socket.io-client": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", - "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.0.tgz", + "integrity": "sha512-C0jdhD5yQahMws9alf/yvtsMGTaIDBnZ8Rb5HU56svyq0l5LIrGzIDZZD5pHQlmzxLuU91Gz+VpQMKgCTNYtkw==", "dev": true, + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", - "engine.io-client": "~6.5.2", + "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, "engines": { @@ -5820,10 +5823,11 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -5953,18 +5957,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -6155,10 +6147,18 @@ "dev": true }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true, + "license": "MIT" }, "node_modules/tinypool": { "version": "1.0.0", @@ -6179,10 +6179,11 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -6277,9 +6278,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6385,14 +6386,15 @@ } }, "node_modules/vite": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", - "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.39", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -6411,6 +6413,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -6428,6 +6431,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -6440,15 +6446,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.2.tgz", + "integrity": "sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -6467,6 +6473,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -6476,29 +6483,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.2", + "@vitest/mocker": "2.1.2", + "@vitest/pretty-format": "^2.1.2", + "@vitest/runner": "2.1.2", + "@vitest/snapshot": "2.1.2", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6513,8 +6521,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, @@ -6723,9 +6731,9 @@ } }, "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz", + "integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==", "dev": true, "engines": { "node": ">=0.4.0" diff --git a/e2e/package.json b/e2e/package.json index 3577bc4510a9e..5ba0693e60564 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.114.0", + "version": "1.117.0", "description": "", "main": "index.js", "type": "module", @@ -25,7 +25,7 @@ "@immich/sdk": "file:../open-api/typescript-sdk", "@playwright/test": "^1.44.1", "@types/luxon": "^3.4.2", - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "@types/oidc-provider": "^8.5.1", "@types/pg": "^8.11.0", "@types/pngjs": "^6.0.4", @@ -37,7 +37,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-unicorn": "^55.0.0", - "exiftool-vendored": "^28.0.0", + "exiftool-vendored": "^28.3.1", "globals": "^15.9.0", "jose": "^5.6.3", "luxon": "^3.4.4", @@ -53,6 +53,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "20.17.0" + "node": "20.18.0" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 55032bd364bee..2576a2c5c945f 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -53,8 +53,10 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'docker compose up --build -V --remove-orphans', + command: 'docker compose up --build --renew-anon-volumes --force-recreate --remove-orphans', url: 'http://127.0.0.1:2285', + stdout: 'pipe', + stderr: 'pipe', reuseExistingServer: true, }, }); diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 7d3c3c6e59ad2..4dd02ec69fc3d 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -76,7 +76,6 @@ describe('/asset', () => { let user2Assets: AssetMediaResponseDto[]; let locationAsset: AssetMediaResponseDto; let ratingAsset: AssetMediaResponseDto; - let facesAsset: AssetMediaResponseDto; const setupTests = async () => { await utils.resetDatabase(); @@ -236,7 +235,7 @@ describe('/asset', () => { await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); // asset faces - facesAsset = await utils.createAsset(admin.accessToken, { + const facesAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'portrait.jpg', bytes: await readFile(facesAssetFilepath), @@ -545,6 +544,48 @@ describe('/asset', () => { expect(status).toEqual(200); }); + it('should not allow linking two photos', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: user1Assets[1].id }); + + expect(body).toEqual(errorDto.badRequest('Live photo video must be a video')); + expect(status).toEqual(400); + }); + + it('should not allow linking a video owned by another user', async () => { + const asset = await utils.createAsset(user2.accessToken, { assetData: { filename: 'example.mp4' } }); + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: asset.id }); + + expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user')); + expect(status).toEqual(400); + }); + + it('should link a motion photo', async () => { + const asset = await utils.createAsset(user1.accessToken, { assetData: { filename: 'example.mp4' } }); + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: asset.id }); + + expect(status).toEqual(200); + expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: asset.id }); + }); + + it('should unlink a motion photo', async () => { + const { status, body } = await request(app) + .put(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ livePhotoVideoId: null }); + + expect(status).toEqual(200); + expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null }); + }); + it('should update date time original when sidecar file contains DateTimeOriginal', async () => { const sidecarData = ` diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 2f6274d1fca4d..9f5adc4e27d92 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -1,11 +1,4 @@ -import { - LibraryResponseDto, - LoginResponseDto, - ScanLibraryDto, - getAllLibraries, - removeOfflineFiles, - scanLibrary, -} from '@immich/sdk'; +import { LibraryResponseDto, LoginResponseDto, getAllLibraries, scanLibrary } from '@immich/sdk'; import { cpSync, existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { userDto, uuidDto } from 'src/fixtures'; @@ -15,8 +8,7 @@ import request from 'supertest'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) => - scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) }); +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); describe('/libraries', () => { let admin: LoginResponseDto; @@ -83,7 +75,7 @@ describe('/libraries', () => { refreshedAt: null, assetCount: 0, importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -270,7 +262,7 @@ describe('/libraries', () => { refreshedAt: null, assetCount: 0, importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -293,14 +285,19 @@ describe('/libraries', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should scan external library', async () => { + it('should import new asset when scanning external library', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, @@ -315,8 +312,13 @@ describe('/libraries', () => { exclusionPatterns: ['**/directoryA'], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -330,8 +332,13 @@ describe('/libraries', () => { importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -340,387 +347,237 @@ describe('/libraries', () => { expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); }); - it('should pick up new files', async () => { + it('should scan multiple import paths with commas', async () => { + // https://github.com/immich-app/immich/issues/10699 const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/folder, a`, `${testAssetDirInternal}/temp/folder, b`], }); - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); + utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 }); - - const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.count).toBe(3); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); - }); - - it('should offline a file missing from disk', async () => { - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(3); - - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetC.png`); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(newAssets.count).toBe(3); + expect(assets.count).toBe(2); + expect(assets.items.find((asset) => asset.originalPath.includes('folder, a'))).toBeDefined(); + expect(assets.items.find((asset) => asset.originalPath.includes('folder, b'))).toBeDefined(); - expect(newAssets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetC.png', - }), - ]), - ); + utils.removeImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); + utils.removeImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); }); - it('should offline a file outside of import paths', async () => { + it('should scan multiple import paths with braces', async () => { + // https://github.com/immich-app/immich/issues/10699 const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/folder{ a`, `${testAssetDirInternal}/temp/folder} b`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); - await request(app) - .put(`/libraries/${library.id}`) + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [`${testAssetDirInternal}/temp/directoryA`] }); + .send(); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + expect(assets.count).toBe(2); + expect(assets.items.find((asset) => asset.originalPath.includes('folder{ a'))).toBeDefined(); + expect(assets.items.find((asset) => asset.originalPath.includes('folder} b'))).toBeDefined(); + + utils.removeImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); + utils.removeImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); }); - it('should offline a file covered by an exclusion pattern', async () => { + it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], }); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - await request(app) - .put(`/libraries/${library.id}`) + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); + + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/directoryB/**'] }); + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - isOffline: false, - originalFileName: 'assetA.png', - }), - expect.objectContaining({ - isOffline: true, - originalFileName: 'assetB.png', - }), - ]), - ); + const { assets } = await utils.metadataSearch(admin.accessToken, { + libraryId: library.id, + model: 'NIKON D750', + }); + expect(assets.count).toBe(1); }); - it('should not try to delete offline files', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - + it('should not reimport unmodified files', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline1`], + importPaths: [`${testAssetDirInternal}/temp`], }); + utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - expect(initialAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, - }); + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); + await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ refreshModifiedFiles: true }); + expect(status).toBe(204); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets).toEqual({ - count: 1, - total: 1, - facets: [], - items: [expect.objectContaining({ originalFileName: 'assetA.png' })], - nextPage: null, + model: 'NIKON D750', }); - - utils.createImageFile(`${testAssetDir}/temp/offline1/assetA.png`); - await removeOfflineFiles({ id: library.id }, { headers: asBearerAuth(admin.accessToken) }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForWebsocketEvent({ event: 'assetDelete', total: 1 }); - - expect(existsSync(`${testAssetDir}/temp/offline1/assetA.png`)).toBe(true); - - utils.removeImageFile(`${testAssetDir}/temp/offline1/assetA.png`); + expect(assets.count).toBe(0); }); - it('should scan new files', async () => { + it('should set an asset offline if its file is missing', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], + importPaths: [`${testAssetDirInternal}/temp/offline`], }); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - utils.createImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); - expect(assets.count).toBe(3); - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - originalFileName: 'assetC.png', - }), - ]), - ); - - utils.removeImageFile(`${testAssetDir}/temp/directoryC/assetC.png`); - }); - - describe('with refreshModifiedFiles=true', () => { - it('should reimport modified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_001); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - - it('should not reimport unmodified files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id, { refreshModifiedFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(0); - }); - }); - - describe('with refreshAllFiles=true', () => { - it('should reimport all files', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); - - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/directoryA/assetB.jpg`); - await utimes(`${testAssetDir}/temp/directoryA/assetB.jpg`, 447_775_200_000); + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id, { refreshAllFiles: true }); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - utils.removeImageFile(`${testAssetDir}/temp/directoryA/assetB.jpg`); + const { status } = await request(app) + .post(`/libraries/${library.id}/scan`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(); + expect(status).toBe(204); - const { assets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - model: 'NIKON D750', - }); - expect(assets.count).toBe(1); - }); - }); - }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); - describe('POST /libraries/:id/removeOffline', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/removeOffline`).send({}); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toEqual(true); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(newAssets.items).toEqual([]); }); - it('should remove offline files', async () => { + it('should set an asset offline its file is not in any import path', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp/offline`], }); - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - }); - expect(initialAssets.count).toBe(2); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); - await scan(admin.accessToken, library.id); - await utils.waitForQueueFinish(admin.accessToken, 'library'); + utils.createDirectory(`${testAssetDir}/temp/another-path/`); - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ importPaths: [`${testAssetDirInternal}/temp/another-path/`] }); const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.count).toBe(1); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); + expect(newAssets.items).toEqual([]); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + utils.removeDirectory(`${testAssetDir}/temp/another-path/`); }); - it('should remove offline files from trash', async () => { + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], + importPaths: [`${testAssetDirInternal}/temp`], }); - utils.createImageFile(`${testAssetDir}/temp/offline/online.png`); - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: initialAssets } = await utils.metadataSearch(admin.accessToken, { + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id, + originalFileName: 'assetB.png', }); + expect(assets.count).toBe(1); - expect(initialAssets.count).toBe(2); - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + await request(app) + .put(`/libraries/${library.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ exclusionPatterns: ['**/directoryB/**'] }); await scan(admin.accessToken, library.id); await utils.waitForQueueFinish(admin.accessToken, 'library'); - const { assets: offlineAssets } = await utils.metadataSearch(admin.accessToken, { - libraryId: library.id, - isOffline: true, - }); - expect(offlineAssets.count).toBe(1); - - const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send(); - expect(status).toBe(204); - await utils.waitForQueueFinish(admin.accessToken, 'library'); - await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); - - const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); + expect(trashedAsset.isTrashed).toBe(true); + expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); + expect(trashedAsset.isOffline).toBe(true); - expect(assets.count).toBe(1); - expect(assets.items[0].isOffline).toBe(false); - expect(assets.items[0].originalPath).toEqual(`${testAssetDirInternal}/temp/offline/online.png`); + const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); - utils.removeImageFile(`${testAssetDir}/temp/offline/online.png`); + expect(newAssets.items).toEqual([ + expect.objectContaining({ + originalFileName: 'assetA.png', + }), + ]); }); - it('should not remove online files', async () => { + it('should not trash an online asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, importPaths: [`${testAssetDirInternal}/temp`], @@ -733,10 +590,11 @@ describe('/libraries', () => { expect(assetsBefore.count).toBeGreaterThan(1); const { status } = await request(app) - .post(`/libraries/${library.id}/removeOffline`) + .post(`/libraries/${library.id}/scan`) .set('Authorization', `Bearer ${admin.accessToken}`) .send(); expect(status).toBe(204); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); @@ -828,7 +686,7 @@ describe('/libraries', () => { }); await scan(admin.accessToken, library.id); - await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 }); + await utils.waitForQueueFinish(admin.accessToken, 'library'); const { status, body } = await request(app) .delete(`/libraries/${library.id}`) diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index 343a7c91d03e6..da5f779cffaad 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -1,8 +1,7 @@ -import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { LoginResponseDto } from '@immich/sdk'; import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; -import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; @@ -11,18 +10,13 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; describe('/map', () => { let websocket: Socket; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); websocket = await utils.connectWebsocket(admin.accessToken); - asset = await utils.createAsset(admin.accessToken); - const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; utils.resetEvents(); const uploadFile = async (input: string) => { @@ -103,63 +97,6 @@ describe('/map', () => { }); }); - describe('GET /map/style.json', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/style.json'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should allow shared link access', async () => { - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Individual, - assetIds: [asset.id], - }); - const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' }); - - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should throw an error if a theme is not light or dark', async () => { - for (const theme of ['dark1', true, 123, '', null, undefined]) { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark'])); - } - }); - - it('should return the light style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'light' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' })); - }); - - it('should return the dark style.json', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should not require admin authentication', async () => { - const { status, body } = await request(app) - .get('/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - }); - describe('GET /map/reverse-geocode', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/map/reverse-geocode'); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index beeaf1cc01ca5..0e5d882f80e50 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -181,7 +181,7 @@ describe('/search', () => { dto: { size: -1.5 }, expected: ['size must not be less than 1', 'size must be an integer number'], }, - ...['isArchived', 'isFavorite', 'isEncoded', 'isMotion', 'isOffline', 'isVisible'].map((value) => ({ + ...['isArchived', 'isFavorite', 'isEncoded', 'isOffline', 'isMotion', 'isVisible'].map((value) => ({ should: `should reject ${value} not a boolean`, dto: { [value]: 'immich' }, expected: [`${value} must be a boolean value`], diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts deleted file mode 100644 index 571d98cda744e..0000000000000 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { LoginResponseDto } from '@immich/sdk'; -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'; - -describe('/server-info', () => { - let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - - beforeAll(async () => { - await utils.resetDatabase(); - admin = await utils.adminSetup({ onboarding: false }); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); - }); - - describe('GET /server-info/about', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server-info/about'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should return about information', async () => { - const { status, body } = await request(app) - .get('/server-info/about') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - version: expect.any(String), - versionUrl: expect.any(String), - repository: 'immich-app/immich', - repositoryUrl: 'https://github.com/immich-app/immich', - build: '1234567890', - buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890', - buildImage: 'e2e', - buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server', - sourceRef: 'e2e', - sourceCommit: 'e2eeeeeeeeeeeeeeeeee', - sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee', - nodejs: expect.any(String), - ffmpeg: expect.any(String), - imagemagick: expect.any(String), - libvips: expect.any(String), - exiftool: expect.any(String), - licensed: false, - }); - }); - }); - - describe('GET /server-info/storage', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server-info/storage'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should return the disk information', async () => { - const { status, body } = await request(app) - .get('/server-info/storage') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - diskAvailable: expect.any(String), - diskAvailableRaw: expect.any(Number), - diskSize: expect.any(String), - diskSizeRaw: expect.any(Number), - diskUsagePercentage: expect.any(Number), - diskUse: expect.any(String), - diskUseRaw: expect.any(Number), - }); - }); - }); - - describe('GET /server-info/ping', () => { - it('should respond with pong', async () => { - const { status, body } = await request(app).get('/server-info/ping'); - expect(status).toBe(200); - expect(body).toEqual({ res: 'pong' }); - }); - }); - - describe('GET /server-info/version', () => { - it('should respond with the server version', async () => { - const { status, body } = await request(app).get('/server-info/version'); - expect(status).toBe(200); - expect(body).toEqual({ - major: expect.any(Number), - minor: expect.any(Number), - patch: expect.any(Number), - }); - }); - }); - - describe('GET /server-info/features', () => { - it('should respond with the server features', async () => { - const { status, body } = await request(app).get('/server-info/features'); - expect(status).toBe(200); - expect(body).toEqual({ - smartSearch: false, - configFile: false, - duplicateDetection: false, - facialRecognition: false, - importFaces: false, - map: true, - reverseGeocoding: true, - oauth: false, - oauthAutoLaunch: false, - passwordLogin: true, - search: true, - sidecar: true, - trash: true, - email: false, - }); - }); - }); - - describe('GET /server-info/config', () => { - it('should respond with the server configuration', async () => { - const { status, body } = await request(app).get('/server-info/config'); - expect(status).toBe(200); - expect(body).toEqual({ - loginPageMessage: '', - oauthButtonText: 'Login with OAuth', - trashDays: 30, - userDeleteDelay: 7, - isInitialized: true, - externalDomain: '', - isOnboarded: false, - }); - }); - }); - - describe('GET /server-info/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/server-info/statistics'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server-info/statistics') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - - it('should return the server stats', async () => { - const { status, body } = await request(app) - .get('/server-info/statistics') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual({ - photos: 0, - usage: 0, - usageByUser: [ - { - quotaSizeInBytes: null, - photos: 0, - usage: 0, - userName: 'Immich Admin', - userId: admin.userId, - videos: 0, - }, - { - quotaSizeInBytes: null, - photos: 0, - usage: 0, - userName: 'User 1', - userId: nonAdmin.userId, - videos: 0, - }, - ], - videos: 0, - }); - }); - }); - - describe('GET /server-info/media-types', () => { - it('should return accepted media types', async () => { - const { status, body } = await request(app).get('/server-info/media-types'); - expect(status).toBe(200); - expect(body).toEqual({ - sidecar: ['.xmp'], - image: expect.any(Array), - video: expect.any(Array), - }); - }); - }); - - describe('GET /server-info/theme', () => { - it('should respond with the server theme', async () => { - const { status, body } = await request(app).get('/server-info/theme'); - expect(status).toBe(200); - expect(body).toEqual({ - customCss: '', - }); - }); - }); -}); diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index b19e6d85c4ad0..3133460adaf2a 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -134,6 +134,8 @@ describe('/server', () => { isInitialized: true, externalDomain: '', isOnboarded: false, + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); }); }); diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 0c28a72825beb..0bfc0ec19be21 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -1,10 +1,13 @@ -import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; +import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; +import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) }); + describe('/trash', () => { let admin: LoginResponseDto; let ws: Socket; @@ -34,13 +37,18 @@ describe('/trash', () => { const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true })); - const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); + const { status, body } = await request(app) + .post('/trash/empty') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); }); it('should empty the trash with archived assets', async () => { @@ -51,13 +59,56 @@ describe('/trash', () => { const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true, isArchived: true })); - const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); + const { status, body } = await request(app) + .post('/trash/empty') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); const after = await getAssetStatistics({ isTrashed: true }, { headers: asBearerAuth(admin.accessToken) }); expect(after.total).toBe(0); + + expect(existsSync(before.originalPath)).toBe(false); + }); + + it('should not delete offline-trashed assets from disk', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.items.length).toBe(1); + const asset = assets.items[0]; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + await utils.waitForQueueFinish(admin.accessToken, 'backgroundTask'); + + const assetAfter = await utils.getAssetInfo(admin.accessToken, asset.id); + expect(assetAfter).toMatchObject({ isTrashed: true, isOffline: true }); + + expect(existsSync(`${testAssetDir}/temp/offline/offline.png`)).toBe(true); + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); }); }); @@ -76,12 +127,46 @@ describe('/trash', () => { const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true })); - const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(204); + const { status, body } = await request(app) + .post('/trash/restore') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false })); }); + + it('should not restore offline-trashed assets', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + + const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + + const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); + }); }); describe('POST /trash/restore/assets', () => { @@ -99,14 +184,48 @@ describe('/trash', () => { const before = await utils.getAssetInfo(admin.accessToken, assetId); expect(before.isTrashed).toBe(true); - const { status } = await request(app) + const { status, body } = await request(app) .post('/trash/restore/assets') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ ids: [assetId] }); - expect(status).toBe(204); + expect(status).toBe(200); + expect(body).toEqual({ count: 1 }); const after = await utils.getAssetInfo(admin.accessToken, assetId); expect(after.isTrashed).toBe(false); }); + + it('should not restore an offline-trashed asset', async () => { + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + importPaths: [`${testAssetDirInternal}/temp/offline`], + }); + + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id }); + expect(assets.count).toBe(1); + const assetId = assets.items[0].id; + + utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); + + await scan(admin.accessToken, library.id); + await utils.waitForQueueFinish(admin.accessToken, 'library'); + + const before = await utils.getAssetInfo(admin.accessToken, assetId); + expect(before.isTrashed).toBe(true); + + const { status } = await request(app) + .post('/trash/restore/assets') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [assetId] }); + expect(status).toBe(200); + + const after = await utils.getAssetInfo(admin.accessToken, assetId); + expect(after.isTrashed).toBe(true); + }); }); }); diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 6ca2225180de6..0148f2e1e938c 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -94,6 +94,7 @@ export const signupResponseDto = { quotaSizeInBytes: null, status: 'active', license: null, + profileChangedAt: expect.any(String), }, }; diff --git a/e2e/src/setup/docker-compose.ts b/e2e/src/setup/docker-compose.ts index 3ae87417a2f94..49a702e776c85 100644 --- a/e2e/src/setup/docker-compose.ts +++ b/e2e/src/setup/docker-compose.ts @@ -12,7 +12,8 @@ const setup = async () => { const timeout = setTimeout(() => _reject(new Error('Timeout starting e2e environment')), 60_000); - const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' }); + const command = 'compose up --build --renew-anon-volumes --force-recreate --remove-orphans'; + const child = spawn('docker', command.split(' '), { stdio: 'pipe' }); child.stdout.on('data', (data) => { const input = data.toString(); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index c67e5696975a9..e21b3bfd14934 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -156,8 +156,7 @@ export const utils = { for (const table of tables) { if (table === 'system_metadata') { - // prevent reverse geocoder from being re-initialized - sql.push(`DELETE FROM "system_metadata" where "key" != 'reverse-geocoding-state';`); + sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`); } else { sql.push(`DELETE FROM ${table} CASCADE;`); } @@ -373,6 +372,12 @@ export const utils = { writeFileSync(path, makeRandomImage()); }, + createDirectory: (path: string) => { + if (!existsSync(dirname(path))) { + mkdirSync(dirname(path), { recursive: true }); + } + }, + removeImageFile: (path: string) => { if (!existsSync(path)) { return; @@ -381,6 +386,14 @@ export const utils = { rmSync(path); }, + removeDirectory: (path: string) => { + if (!existsSync(path)) { + return; + } + + rmSync(path); + }, + getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) => diff --git a/localizely.yml b/localizely.yml index 343464284a876..5d119fe9d8e49 100644 --- a/localizely.yml +++ b/localizely.yml @@ -52,7 +52,7 @@ download: locale_code: nb-NO - file: mobile/assets/i18n/sv-SE.json locale_code: sv-SE - - file: mobile/assets/i18n/mn.json + - file: mobile/assets/i18n/mn-MN.json locale_code: mn - file: mobile/assets/i18n/ko-KR.json locale_code: ko-KR diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index f680aac826af3..155d78f4a34c3 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:20c1819af5af3acba0b2b66074a2615e398ceee6842adf03cd7ad5f8d0ee3daf AS builder-cpu +FROM python:3.11-bookworm@sha256:3cdce69fd5663ca47c420ec4d4df8e3545519a4030372f7d2064fb1be2279844 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,23 +34,27 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:ed4e985674f478c90ce879e9aa224fbb772c84e39b4aed5155b9e2280f131039 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:5501a4fe605abe24de87c2f3d6cf9fd760354416a0cad0296cf284fddcdca9e2 AS prod-cpu FROM prod-cpu AS prod-openvino RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \ dpkg -i *.deb && \ rm *.deb && \ apt-get remove wget -yqq && \ rm -rf /var/lib/apt/lists/* -FROM nvidia/cuda:12.3.2-cudnn9-runtime-ubuntu22.04@sha256:fa44193567d1908f7ca1f3abf8623ce9c63bc8cba7bcfdb32702eb04d326f7a8 AS prod-cuda +FROM nvidia/cuda:12.2.2-runtime-ubuntu22.04@sha256:94c1577b2cd9dd6c0312dc04dff9cb2fdce2b268018abc3d7c2dbcacf1155000 AS prod-cuda + +RUN apt-get update && \ + apt-get install --no-install-recommends -yqq libcudnn9-cuda-12 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3 COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11 @@ -100,7 +104,7 @@ RUN echo "hard core 0" >> /etc/security/limits.conf && \ COPY --from=builder /opt/venv /opt/venv COPY ann/ann.py /usr/src/ann/ann.py -COPY start.sh log_conf.json ./ +COPY start.sh log_conf.json gunicorn_conf.py ./ COPY app . ENTRYPOINT ["tini", "--"] CMD ["./start.sh"] diff --git a/machine-learning/app/config.py b/machine-learning/app/config.py index af2d0aa4b91a9..52be0a30c840b 100644 --- a/machine-learning/app/config.py +++ b/machine-learning/app/config.py @@ -39,6 +39,10 @@ class Config: case_sensitive = False env_nested_delimiter = "__" + @property + def device_id(self) -> str: + return os.environ.get("MACHINE_LEARNING_DEVICE_ID", "0") + class LogSettings(BaseSettings): immich_log_level: str = "info" diff --git a/machine-learning/app/models/base.py b/machine-learning/app/models/base.py index 1c019969b4790..3bbd1a02892d4 100644 --- a/machine-learning/app/models/base.py +++ b/machine-learning/app/models/base.py @@ -71,7 +71,6 @@ def _download(self) -> None: f"immich-app/{clean_name(self.model_name)}", cache_dir=self.cache_dir, local_dir=self.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=ignore_patterns, ) diff --git a/machine-learning/app/models/facial_recognition/recognition.py b/machine-learning/app/models/facial_recognition/recognition.py index d9ceb12b6d590..c060bdd61634f 100644 --- a/machine-learning/app/models/facial_recognition/recognition.py +++ b/machine-learning/app/models/facial_recognition/recognition.py @@ -13,7 +13,6 @@ from app.models.base import InferenceModel from app.models.transforms import decode_cv2 from app.schemas import FaceDetectionOutput, FacialRecognitionOutput, ModelFormat, ModelSession, ModelTask, ModelType -from app.sessions import has_batch_axis class FaceRecognizer(InferenceModel): @@ -27,7 +26,7 @@ def __init__(self, model_name: str, min_score: float = 0.7, **model_kwargs: Any) def _load(self) -> ModelSession: session = self._make_session(self.model_path) - if self.batch and not has_batch_axis(session): + if self.batch and str(session.get_inputs()[0].shape[0]) != "batch": self._add_batch_axis(self.model_path) session = self._make_session(self.model_path) self.model = ArcFaceONNX( diff --git a/machine-learning/app/sessions/__init__.py b/machine-learning/app/sessions/__init__.py index e0c00ea4a0472..e69de29bb2d1d 100644 --- a/machine-learning/app/sessions/__init__.py +++ b/machine-learning/app/sessions/__init__.py @@ -1,5 +0,0 @@ -from app.schemas import ModelSession - - -def has_batch_axis(session: ModelSession) -> bool: - return not isinstance(session.get_inputs()[0].shape[0], int) or session.get_inputs()[0].shape[0] < 0 diff --git a/machine-learning/app/sessions/ort.py b/machine-learning/app/sessions/ort.py index 1a244b7c5750e..00c7ad50a9ac7 100644 --- a/machine-learning/app/sessions/ort.py +++ b/machine-learning/app/sessions/ort.py @@ -86,11 +86,13 @@ def _provider_options_default(self) -> list[dict[str, Any]]: provider_options = [] for provider in self.providers: match provider: - case "CPUExecutionProvider" | "CUDAExecutionProvider": + case "CPUExecutionProvider": options = {"arena_extend_strategy": "kSameAsRequested"} + case "CUDAExecutionProvider": + options = {"arena_extend_strategy": "kSameAsRequested", "device_id": settings.device_id} case "OpenVINOExecutionProvider": options = { - "device_type": "GPU", + "device_type": f"GPU.{settings.device_id}", "precision": "FP32", "cache_dir": (self.model_path.parent / "openvino").as_posix(), } diff --git a/machine-learning/app/test_main.py b/machine-learning/app/test_main.py index 17fdb5b1fadd2..ad8986d57240b 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -124,7 +124,6 @@ def test_download(self, snapshot_download: mock.Mock) -> None: "immich-app/ViT-B-32__openai", cache_dir=encoder.cache_dir, local_dir=encoder.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=["*.armnn"], ) @@ -136,7 +135,6 @@ def test_download_downloads_armnn_if_preferred_format(self, snapshot_download: m "immich-app/ViT-B-32__openai", cache_dir=encoder.cache_dir, local_dir=encoder.cache_dir, - local_dir_use_symlinks=False, ignore_patterns=[], ) @@ -212,10 +210,24 @@ def test_sets_default_provider_options(self, ov_device_ids: list[str]) -> None: session = OrtSession(model_path, providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"]) assert session.provider_options == [ - {"device_type": "GPU", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"}, + {"device_type": "GPU.0", "precision": "FP32", "cache_dir": "/cache/ViT-B-32__openai/openvino"}, {"arena_extend_strategy": "kSameAsRequested"}, ] + def test_sets_device_id_for_openvino(self) -> None: + os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + + session = OrtSession("ViT-B-32__openai", providers=["OpenVINOExecutionProvider"]) + + assert session.provider_options[0]["device_type"] == "GPU.1" + + def test_sets_device_id_for_cuda(self) -> None: + os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1" + + session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider"]) + + assert session.provider_options[0]["device_id"] == "1" + def test_sets_provider_options_kwarg(self) -> None: session = OrtSession( "ViT-B-32__openai", diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index eaa35d14be0dd..195e64ab35ad6 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:29174348bd09352e5f1b1f6756cf1d00021487b8340fae040e91e4f98e954ce5 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:e3797091302382ea841498bc93a7b0a50f7c1448333d5e946d2d1608d0c5f43d AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/gunicorn_conf.py b/machine-learning/gunicorn_conf.py new file mode 100644 index 0000000000000..efec3a95aa452 --- /dev/null +++ b/machine-learning/gunicorn_conf.py @@ -0,0 +1,12 @@ +import os + +from gunicorn.arbiter import Arbiter +from gunicorn.workers.base import Worker + +device_ids = os.environ.get("MACHINE_LEARNING_DEVICE_IDS", "0").replace(" ", "").split(",") +env = os.environ + + +# Round-robin device assignment for each worker +def pre_fork(arbiter: Arbiter, _: Worker) -> None: + env["MACHINE_LEARNING_DEVICE_ID"] = device_ids[len(arbiter.WORKERS) % len(device_ids)] diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index bd09bd8469e67..2afa60e701995 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "aiocache" -version = "0.12.2" +version = "0.12.3" description = "multi backend asyncio cache" optional = false python-versions = "*" files = [ - {file = "aiocache-0.12.2-py2.py3-none-any.whl", hash = "sha256:9b6fa30634ab0bfc3ecc44928a91ff07c6ea16d27d55469636b296ebc6eb5918"}, - {file = "aiocache-0.12.2.tar.gz", hash = "sha256:b41c9a145b050a5dcbae1599f847db6dd445193b1f3bd172d8e0fe0cb9e96684"}, + {file = "aiocache-0.12.3-py2.py3-none-any.whl", hash = "sha256:889086fc24710f431937b87ad3720a289f7fc31c4fd8b68e9f918b9bacd8270d"}, + {file = "aiocache-0.12.3.tar.gz", hash = "sha256:f528b27bf4d436b497a1d0d1a8f59a542c153ab1e37c3621713cb376d44c4713"}, ] [package.extras] @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.112.2" +version = "0.115.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.112.2-py3-none-any.whl", hash = "sha256:c023f74768f187af142c2fe5ff9e4ca3c4c1940bbde7df008cb283532422a23f"}, - {file = "fastapi_slim-0.112.2.tar.gz", hash = "sha256:75b8eb0c6ee05a20270da7a527ac7ad53b83414602f42b68f7027484dab3aedb"}, + {file = "fastapi_slim-0.115.0-py3-none-any.whl", hash = "sha256:27ab44da95b622e68be7a19f06df1960a320b9d94e689b0adfc055bb26ee9be7"}, + {file = "fastapi_slim-0.115.0.tar.gz", hash = "sha256:b4b962ca2aa0a31010dafdad3d4da99d368a5591223304c6fb385712fad7feb6"}, ] [package.dependencies] @@ -1237,13 +1237,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.24.6" +version = "0.25.1" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" optional = false python-versions = ">=3.8.0" files = [ - {file = "huggingface_hub-0.24.6-py3-none-any.whl", hash = "sha256:a990f3232aa985fe749bc9474060cbad75e8b2f115f6665a9fda5b9c97818970"}, - {file = "huggingface_hub-0.24.6.tar.gz", hash = "sha256:cc2579e761d070713eaa9c323e3debe39d5b464ae3a7261c39a9195b27bb8000"}, + {file = "huggingface_hub-0.25.1-py3-none-any.whl", hash = "sha256:a5158ded931b3188f54ea9028097312cb0acd50bffaaa2612014c3c526b44972"}, + {file = "huggingface_hub-0.25.1.tar.gz", hash = "sha256:9ff7cb327343211fbd06e2b149b8f362fd1e389454f3f14c6db75a4999ee20ff"}, ] [package.dependencies] @@ -1531,13 +1531,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"] [[package]] name = "locust" -version = "2.31.5" +version = "2.31.8" description = "Developer-friendly load testing framework" optional = false python-versions = ">=3.9" files = [ - {file = "locust-2.31.5-py3-none-any.whl", hash = "sha256:2904ff6307d54d3202c9ebd776f9170214f6dfbe4059504dad9e3ffaca03f600"}, - {file = "locust-2.31.5.tar.gz", hash = "sha256:14b2fa6f95bf248668e6dc92d100a44f06c5dcb1c26f88a5442bcaaee18faceb"}, + {file = "locust-2.31.8-py3-none-any.whl", hash = "sha256:4194e3d4a0472f1206c51532ed527017f3da1a7d1037ca4b2f0735d5dcd2f78f"}, + {file = "locust-2.31.8.tar.gz", hash = "sha256:b240c0d3e1724317d9211e81e99fbe42a3469071ef4d34d2ae6a727776d56377"}, ] [package.dependencies] @@ -1963,36 +1963,36 @@ reference = ["Pillow", "google-re2"] [[package]] name = "onnxruntime" -version = "1.19.0" +version = "1.19.2" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime-1.19.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6ce22a98dfec7b646ae305f52d0ce14a189a758b02ea501860ca719f4b0ae04b"}, - {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19019c72873f26927aa322c54cf2bf7312b23451b27451f39b88f57016c94f8b"}, - {file = "onnxruntime-1.19.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8eaa16df99171dc636e30108d15597aed8c4c2dd9dbfdd07cc464d57d73fb275"}, - {file = "onnxruntime-1.19.0-cp310-cp310-win32.whl", hash = "sha256:0eb0f8dbe596fd0f4737fe511fdbb17603853a7d204c5b2ca38d3c7808fc556b"}, - {file = "onnxruntime-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:616092d54ba8023b7bc0a5f6d900a07a37cc1cfcc631873c15f8c1d6e9e184d4"}, - {file = "onnxruntime-1.19.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a2b53b3c287cd933e5eb597273926e899082d8c84ab96e1b34035764a1627e17"}, - {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e94984663963e74fbb468bde9ec6f19dcf890b594b35e249c4dc8789d08993c5"}, - {file = "onnxruntime-1.19.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f379d1f050cfb55ce015d53727b78ee362febc065c38eed81512b22b757da73"}, - {file = "onnxruntime-1.19.0-cp311-cp311-win32.whl", hash = "sha256:4ccb48faea02503275ae7e79e351434fc43c294c4cb5c4d8bcb7479061396614"}, - {file = "onnxruntime-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:9cdc8d311289a84e77722de68bd22b8adfb94eea26f4be6f9e017350faac8b18"}, - {file = "onnxruntime-1.19.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1b59eaec1be9a8613c5fdeaafe67f73a062edce3ac03bbbdc9e2d98b58a30617"}, - {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4144d014a4b25184e63ce7a463a2e7796e2f3df931fccc6a6aefa6f1365dc5"}, - {file = "onnxruntime-1.19.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10d7e7d4ca7021ce7f29a66dbc6071addf2de5839135339bd855c6d9c2bba371"}, - {file = "onnxruntime-1.19.0-cp312-cp312-win32.whl", hash = "sha256:87f2c58b577a1fb31dc5d92b647ecc588fd5f1ea0c3ad4526f5f80a113357c8d"}, - {file = "onnxruntime-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:8a1f50d49676d7b69566536ff039d9e4e95fc482a55673719f46528218ecbb94"}, - {file = "onnxruntime-1.19.0-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:71423c8c4b2d7a58956271534302ec72721c62a41efd0c4896343249b8399ab0"}, - {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d63630d45e9498f96e75bbeb7fd4a56acb10155de0de4d0e18d1b6cbb0b358a"}, - {file = "onnxruntime-1.19.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3bfd15db1e8794d379a86c1a9116889f47f2cca40cc82208fc4f7e8c38e8522"}, - {file = "onnxruntime-1.19.0-cp38-cp38-win32.whl", hash = "sha256:3b098003b6b4cb37cc84942e5f1fe27f945dd857cbd2829c824c26b0ba4a247e"}, - {file = "onnxruntime-1.19.0-cp38-cp38-win_amd64.whl", hash = "sha256:cea067a6541d6787d903ee6843401c5b1332a266585160d9700f9f0939443886"}, - {file = "onnxruntime-1.19.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c4fcff12dc5ca963c5f76b9822bb404578fa4a98c281e8c666b429192799a099"}, - {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6dcad8a4db908fbe70b98c79cea1c8b6ac3316adf4ce93453136e33a524ac59"}, - {file = "onnxruntime-1.19.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bc449907c6e8d99eee5ae5cc9c8fdef273d801dcd195393d3f9ab8ad3f49522"}, - {file = "onnxruntime-1.19.0-cp39-cp39-win32.whl", hash = "sha256:947febd48405afcf526e45ccff97ff23b15e530434705f734870d22ae7fcf236"}, - {file = "onnxruntime-1.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:f60be47eff5ee77fd28a466b0fd41d7debc42a32179d1ddb21e05d6067d7b48b"}, + {file = "onnxruntime-1.19.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:84fa57369c06cadd3c2a538ae2a26d76d583e7c34bdecd5769d71ca5c0fc750e"}, + {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdc471a66df0c1cdef774accef69e9f2ca168c851ab5e4f2f3341512c7ef4666"}, + {file = "onnxruntime-1.19.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3a4ce906105d99ebbe817f536d50a91ed8a4d1592553f49b3c23c4be2560ae6"}, + {file = "onnxruntime-1.19.2-cp310-cp310-win32.whl", hash = "sha256:4b3d723cc154c8ddeb9f6d0a8c0d6243774c6b5930847cc83170bfe4678fafb3"}, + {file = "onnxruntime-1.19.2-cp310-cp310-win_amd64.whl", hash = "sha256:17ed7382d2c58d4b7354fb2b301ff30b9bf308a1c7eac9546449cd122d21cae5"}, + {file = "onnxruntime-1.19.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d863e8acdc7232d705d49e41087e10b274c42f09e259016a46f32c34e06dc4fd"}, + {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dfe4f660a71b31caa81fc298a25f9612815215a47b286236e61d540350d7b6"}, + {file = "onnxruntime-1.19.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a36511dc07c5c964b916697e42e366fa43c48cdb3d3503578d78cef30417cb84"}, + {file = "onnxruntime-1.19.2-cp311-cp311-win32.whl", hash = "sha256:50cbb8dc69d6befad4746a69760e5b00cc3ff0a59c6c3fb27f8afa20e2cab7e7"}, + {file = "onnxruntime-1.19.2-cp311-cp311-win_amd64.whl", hash = "sha256:1c3e5d415b78337fa0b1b75291e9ea9fb2a4c1f148eb5811e7212fed02cfffa8"}, + {file = "onnxruntime-1.19.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:68e7051bef9cfefcbb858d2d2646536829894d72a4130c24019219442b1dd2ed"}, + {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d366fbcc205ce68a8a3bde2185fd15c604d9645888703785b61ef174265168"}, + {file = "onnxruntime-1.19.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:477b93df4db467e9cbf34051662a4b27c18e131fa1836e05974eae0d6e4cf29b"}, + {file = "onnxruntime-1.19.2-cp312-cp312-win32.whl", hash = "sha256:9a174073dc5608fad05f7cf7f320b52e8035e73d80b0a23c80f840e5a97c0147"}, + {file = "onnxruntime-1.19.2-cp312-cp312-win_amd64.whl", hash = "sha256:190103273ea4507638ffc31d66a980594b237874b65379e273125150eb044857"}, + {file = "onnxruntime-1.19.2-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:636bc1d4cc051d40bc52e1f9da87fbb9c57d9d47164695dfb1c41646ea51ea66"}, + {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bd8b875757ea941cbcfe01582970cc299893d1b65bd56731e326a8333f638a3"}, + {file = "onnxruntime-1.19.2-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2046fc9560f97947bbc1acbe4c6d48585ef0f12742744307d3364b131ac5778"}, + {file = "onnxruntime-1.19.2-cp38-cp38-win32.whl", hash = "sha256:31c12840b1cde4ac1f7d27d540c44e13e34f2345cf3642762d2a3333621abb6a"}, + {file = "onnxruntime-1.19.2-cp38-cp38-win_amd64.whl", hash = "sha256:016229660adea180e9a32ce218b95f8f84860a200f0f13b50070d7d90e92956c"}, + {file = "onnxruntime-1.19.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:006c8d326835c017a9e9f74c9c77ebb570a71174a1e89fe078b29a557d9c3848"}, + {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df2a94179a42d530b936f154615b54748239c2908ee44f0d722cb4df10670f68"}, + {file = "onnxruntime-1.19.2-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fae4b4de45894b9ce7ae418c5484cbf0341db6813effec01bb2216091c52f7fb"}, + {file = "onnxruntime-1.19.2-cp39-cp39-win32.whl", hash = "sha256:dc5430f473e8706fff837ae01323be9dcfddd3ea471c900a91fa7c9b807ec5d3"}, + {file = "onnxruntime-1.19.2-cp39-cp39-win_amd64.whl", hash = "sha256:38475e29a95c5f6c62c2c603d69fc7d4c6ccbf4df602bd567b86ae1138881c49"}, ] [package.dependencies] @@ -2473,13 +2473,13 @@ files = [ [[package]] name = "pytest" -version = "8.3.2" +version = "8.3.3" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.8" files = [ - {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, - {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, + {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, + {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, ] [package.dependencies] @@ -2576,18 +2576,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.9" +version = "0.0.12" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, - {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, + {file = "python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf"}, + {file = "python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb"}, ] -[package.extras] -dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] - [[package]] name = "pywin32" version = "306" @@ -2816,47 +2813,48 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.8.0" +version = "13.9.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.8.0" files = [ - {file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"}, - {file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"}, + {file = "rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1"}, + {file = "rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c"}, ] [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.3" +version = "0.6.8" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.3-py3-none-linux_armv6l.whl", hash = "sha256:97f58fda4e309382ad30ede7f30e2791d70dd29ea17f41970119f55bdb7a45c3"}, - {file = "ruff-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b061e49b5cf3a297b4d1c27ac5587954ccb4ff601160d3d6b2f70b1622194dc"}, - {file = "ruff-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:34e2824a13bb8c668c71c1760a6ac7d795ccbd8d38ff4a0d8471fdb15de910b1"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bddfbb8d63c460f4b4128b6a506e7052bad4d6f3ff607ebbb41b0aa19c2770d1"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ced3eeb44df75353e08ab3b6a9e113b5f3f996bea48d4f7c027bc528ba87b672"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47021dff5445d549be954eb275156dfd7c37222acc1e8014311badcb9b4ec8c1"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d7bd20dc07cebd68cc8bc7b3f5ada6d637f42d947c85264f94b0d1cd9d87384"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:500f166d03fc6d0e61c8e40a3ff853fa8a43d938f5d14c183c612df1b0d6c58a"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42844ff678f9b976366b262fa2d1d1a3fe76f6e145bd92c84e27d172e3c34500"}, - {file = "ruff-0.6.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70452a10eb2d66549de8e75f89ae82462159855e983ddff91bc0bce6511d0470"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65a533235ed55f767d1fc62193a21cbf9e3329cf26d427b800fdeacfb77d296f"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2e2c23cef30dc3cbe9cc5d04f2899e7f5e478c40d2e0a633513ad081f7361b5"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8a136aa7d228975a6aee3dd8bea9b28e2b43e9444aa678fb62aeb1956ff2351"}, - {file = "ruff-0.6.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f92fe93bc72e262b7b3f2bba9879897e2d58a989b4714ba6a5a7273e842ad2f8"}, - {file = "ruff-0.6.3-py3-none-win32.whl", hash = "sha256:7a62d3b5b0d7f9143d94893f8ba43aa5a5c51a0ffc4a401aa97a81ed76930521"}, - {file = "ruff-0.6.3-py3-none-win_amd64.whl", hash = "sha256:746af39356fee2b89aada06c7376e1aa274a23493d7016059c3a72e3b296befb"}, - {file = "ruff-0.6.3-py3-none-win_arm64.whl", hash = "sha256:14a9528a8b70ccc7a847637c29e56fd1f9183a9db743bbc5b8e0c4ad60592a82"}, - {file = "ruff-0.6.3.tar.gz", hash = "sha256:183b99e9edd1ef63be34a3b51fee0a9f4ab95add123dbf89a71f7b1f0c991983"}, + {file = "ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2"}, + {file = "ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c"}, + {file = "ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44"}, + {file = "ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a"}, + {file = "ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263"}, + {file = "ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc"}, + {file = "ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18"}, ] [[package]] @@ -3269,13 +3267,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.30.6" +version = "0.31.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.8" files = [ - {file = "uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5"}, - {file = "uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788"}, + {file = "uvicorn-0.31.0-py3-none-any.whl", hash = "sha256:cac7be4dd4d891c363cd942160a7b02e69150dcbc7a36be04d5f4af4b17c8ced"}, + {file = "uvicorn-0.31.0.tar.gz", hash = "sha256:13bc21373d103859f68fe739608e2eb054a816dea79189bc3ca08ea89a275906"}, ] [package.dependencies] @@ -3607,4 +3605,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "b2b053886ca1dd3a3305c63caf155b1976dfc4066f72f5d1ecfc42099db34aab" +content-hash = "45c4d57450fbdd0c24a8e7e00ebd023412fa6db04700d0f3818c39c8777d0e31" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index a69fb33a8d50e..fec660a79f631 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.114.0" +version = "1.117.0" description = "" authors = ["Hau Tran "] readme = "README.md" @@ -51,7 +51,7 @@ onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"} optional = true [tool.poetry.group.openvino.dependencies] -onnxruntime-openvino = "^1.17.1" +onnxruntime-openvino = ">=1.17.1,<1.19.0" [tool.poetry.group.armnn] optional = true diff --git a/machine-learning/start.sh b/machine-learning/start.sh index c3fda523df832..552cca1f5e5f5 100755 --- a/machine-learning/start.sh +++ b/machine-learning/start.sh @@ -17,6 +17,7 @@ fi gunicorn app.main:app \ -k app.config.CustomUvicornWorker \ + -c gunicorn_conf.py \ -b "$IMMICH_HOST":"$IMMICH_PORT" \ -w "$MACHINE_LEARNING_WORKERS" \ -t "$MACHINE_LEARNING_WORKER_TIMEOUT" \ diff --git a/mobile/.fvmrc b/mobile/.fvmrc index 971587f297946..ee6eaac06fefc 100644 --- a/mobile/.fvmrc +++ b/mobile/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.24.0" + "flutter": "3.24.3" } diff --git a/mobile/.isar-cargo.lock b/mobile/.isar-cargo.lock new file mode 100644 index 0000000000000..a7b1dd37b9fbe --- /dev/null +++ b/mobile/.isar-cargo.lock @@ -0,0 +1,859 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 1.0.109", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" + +[[package]] +name = "cc" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "float_next_after" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc612c5837986b7104a87a0df74a5460931f1c5274be12f8d0f40aa2f30d632" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "intmap" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee87fd093563344074bacf24faa0bb0227fb6969fb223e922db798516de924d6" + +[[package]] +name = "isar" +version = "0.0.0" +dependencies = [ + "dirs", + "intmap", + "isar-core", + "itertools", + "jni", + "ndk-context", + "objc", + "objc-foundation", + "once_cell", + "paste", + "serde_json", + "threadpool", + "unicode-segmentation", +] + +[[package]] +name = "isar-core" +version = "0.0.0" +dependencies = [ + "byteorder", + "cfg-if", + "crossbeam-channel", + "enum_dispatch", + "float_next_after", + "intmap", + "itertools", + "libc", + "mdbx-sys", + "once_cell", + "paste", + "rand", + "serde", + "serde_json", + "snafu", + "widestring", + "xxhash-rust", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "jni" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c" +dependencies = [ + "cesu8", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "libloading" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +dependencies = [ + "cfg-if", + "windows-targets", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "mdbx-sys" +version = "0.0.0" +dependencies = [ + "bindgen", + "cc", + "cmake", + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ea5043e58958ee56f3e15a90aee535795cd7dfd319846288d93c5b57d85cbe" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "snafu" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "widestring" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xxhash-rust" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a5cbf750400958819fb6178eaa83bee5cd9c29a26a40cc241df8c70fdd46984" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] diff --git a/mobile/.vscode/settings.json b/mobile/.vscode/settings.json index aa43dab3fb008..ceaf9a6ab88dc 100644 --- a/mobile/.vscode/settings.json +++ b/mobile/.vscode/settings.json @@ -1,5 +1,5 @@ { - "dart.flutterSdkPath": ".fvm/versions/3.24.0", + "dart.flutterSdkPath": ".fvm/versions/3.24.3", "search.exclude": { "**/.fvm": true }, diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index fe5729fc60fb2..80514f1603b0d 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -36,8 +36,73 @@ analyzer: - openapi/** - lib/generated_plugin_registrant.dart -plugins: - - custom_lint + plugins: + - custom_lint + +custom_lint: + debug: true + rules: + - avoid_build_context_in_providers: false + - avoid_public_notifier_properties: false + - avoid_manual_providers_as_generated_provider_dependency: false + - unsupported_provider_value: false + - import_rule_photo_manager: + message: photo_manager must only be used in MediaRepositories + restrict: package:photo_manager + allowed: + # required / wanted + - 'lib/repositories/{album,asset,file}_media.repository.dart' + # acceptable exceptions for the time being + - lib/entities/asset.entity.dart # to provide local AssetEntity for now + - lib/providers/image/immich_local_{image,thumbnail}_provider.dart # accesses thumbnails via PhotoManager + # refactor to make the providers and services testable + - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler + - lib/services/{background,backup}.service.dart # uses only PMProgressHandler + - import_rule_isar: + message: isar must only be used in entities and repositories + restrict: package:isar + allowed: + # required / wanted + - lib/entities/*.entity.dart + - lib/repositories/{album,asset,backup,database,etag,exif_info,user}.repository.dart + # acceptable exceptions for the time being (until Isar is fully replaced) + - integration_test/test_utils/general_helper.dart + - lib/main.dart + - lib/pages/common/album_asset_selection.page.dart + - lib/routing/router.dart + - lib/services/immich_logger.service.dart # not really a service... more a util + - lib/utils/{db,migration,renderlist_generator}.dart + - lib/widgets/asset_grid/asset_grid_data_structure.dart + - test/**.dart + # refactor the remaining providers + - lib/providers/{archive,asset,authentication,db,favorite,partner,trash,user}.provider.dart + - lib/providers/{album/album,album/shared_album,asset_viewer/asset_stack,asset_viewer/render_list,backup/backup,search/all_motion_photos,search/recently_added_asset}.provider.dart + + - import_rule_openapi: + message: openapi must only be used through ApiRepositories + restrict: package:openapi + allowed: + # requried / wanted + - lib/repositories/*_api.repository.dart + # acceptable exceptions for the time being + - lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities + - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine + - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... + # refactor + - lib/models/map/map_marker.model.dart + - lib/models/server_info/server_{config,disk_info,features,version}.model.dart + - lib/models/shared_link/shared_link.model.dart + - lib/providers/asset_viewer/asset_people.provider.dart + - lib/providers/authentication.provider.dart + - lib/providers/image/immich_remote_{image,thumbnail}_provider.dart + - lib/providers/map/map_state.provider.dart + - lib/providers/search/{search,search_filter}.provider.dart + - lib/providers/websocket.provider.dart + - lib/routing/auth_guard.dart + - lib/services/{api,asset,backup,memory,oauth,search,shared_link,stack,trash}.service.dart + - lib/widgets/album/album_thumbnail_listtile.dart + - lib/widgets/forms/login/login_form.dart + - lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart dart_code_metrics: metrics: diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index c127032b19ee2..7f5e9c56c599a 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 158, - "android.injected.version.name" => "1.114.0", + "android.injected.version.code" => 162, + "android.injected.version.name" => "1.117.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') diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 324c9069fdf46..ac5bd73604bc2 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -588,5 +588,17 @@ "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack" -} \ No newline at end of file + "viewer_unstack": "Un-Stack", + "filter": "Filter", + "downloading_media": "Downloading media", + "download_finished": "Download finished", + "download_filename": "file: {}", + "downloading": "Downloading...", + "download_complete": "Download complete", + "download_failed": "Download failed", + "download_canceled": "Download canceled", + "download_paused": "Download paused", + "download_enqueue": "Download enqueued", + "download_notfound": "Download not found", + "download_waiting_to_retry": "Waiting to retry" +} diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index ea0b328a808d4..8cfae94c005d1 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -414,7 +414,7 @@ "search_filter_people_title": "Select people", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", - "search_page_motion_photos": "Fotos en .ovimiento", + "search_page_motion_photos": "Fotos en movimiento", "search_page_no_objects": "No hay información de objetos disponible", "search_page_no_places": "No hay información de lugares disponible", "search_page_people": "Personas", @@ -589,4 +589,4 @@ "viewer_remove_from_stack": "Eliminar de la pila", "viewer_stack_use_as_main_asset": "Utilizar como recurso principal", "viewer_unstack": "Desapilar" -} \ No newline at end of file +} diff --git a/mobile/assets/i18n/mn.json b/mobile/assets/i18n/mn-MN.json similarity index 99% rename from mobile/assets/i18n/mn.json rename to mobile/assets/i18n/mn-MN.json index cf951cea0b50b..54697af5da324 100644 --- a/mobile/assets/i18n/mn.json +++ b/mobile/assets/i18n/mn-MN.json @@ -589,4 +589,4 @@ "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" -} \ No newline at end of file +} diff --git a/mobile/immich_lint/analysis_options.yaml b/mobile/immich_lint/analysis_options.yaml new file mode 100644 index 0000000000000..572dd239d0976 --- /dev/null +++ b/mobile/immich_lint/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart new file mode 100644 index 0000000000000..65f3fc18f30ea --- /dev/null +++ b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart @@ -0,0 +1,86 @@ +import 'package:analyzer/error/listener.dart'; +import 'package:analyzer/error/error.dart' show ErrorSeverity; +import 'package:custom_lint_builder/custom_lint_builder.dart'; +// ignore: depend_on_referenced_packages +import 'package:glob/glob.dart'; + +PluginBase createPlugin() => ImmichLinter(); + +class ImmichLinter extends PluginBase { + @override + List getLintRules(CustomLintConfigs configs) { + final List rules = []; + for (final entry in configs.rules.entries) { + if (entry.value.enabled && entry.key.startsWith("import_rule_")) { + final code = makeCode(entry.key, entry.value); + final allowedPaths = getStrings(entry.value, "allowed"); + final forbiddenPaths = getStrings(entry.value, "forbidden"); + final restrict = getStrings(entry.value, "restrict"); + rules.add(ImportRule(code, buildGlob(allowedPaths), + buildGlob(forbiddenPaths), restrict)); + } + } + return rules; + } + + static makeCode(String name, LintOptions options) => LintCode( + name: name, + problemMessage: options.json["message"] as String, + errorSeverity: ErrorSeverity.WARNING, + ); + + static List getStrings(LintOptions options, String field) { + final List result = []; + final excludeOption = options.json[field]; + if (excludeOption is String) { + result.add(excludeOption); + } else if (excludeOption is List) { + result.addAll(excludeOption.map((option) => option)); + } + return result; + } + + Glob? buildGlob(List globs) { + if (globs.isEmpty) return null; + if (globs.length == 1) return Glob(globs[0], caseSensitive: true); + return Glob("{${globs.join(",")}}", caseSensitive: true); + } +} + +// ignore: must_be_immutable +class ImportRule extends DartLintRule { + ImportRule(LintCode code, this._allowed, this._forbidden, this._restrict) + : super(code: code); + + final Glob? _allowed; + final Glob? _forbidden; + final List _restrict; + int _rootOffset = -1; + + @override + void run( + CustomLintResolver resolver, + ErrorReporter reporter, + CustomLintContext context, + ) { + if (_rootOffset == -1) { + const project = "/immich/mobile/"; + _rootOffset = resolver.path.indexOf(project) + project.length; + } + final path = resolver.path.substring(_rootOffset); + + if ((_allowed != null && _allowed!.matches(path)) && + (_forbidden == null || !_forbidden!.matches(path))) return; + + context.registry.addImportDirective((node) { + final uri = node.uri.stringValue; + if (uri == null) return; + for (final restricted in _restrict) { + if (uri.startsWith(restricted) == true) { + reporter.atNode(node, code); + return; + } + } + }); + } +} diff --git a/mobile/immich_lint/pubspec.lock b/mobile/immich_lint/pubspec.lock new file mode 100644 index 0000000000000..e81bad7da2a58 --- /dev/null +++ b/mobile/immich_lint/pubspec.lock @@ -0,0 +1,370 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77" + url: "https://pub.dev" + source: hosted + version: "73.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.2" + analyzer: + dependency: "direct main" + description: + name: analyzer + sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a" + url: "https://pub.dev" + source: hosted + version: "6.8.0" + analyzer_plugin: + dependency: "direct main" + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" + args: + dependency: transitive + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" + collection: + dependency: transitive + description: + name: collection + sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + url: "https://pub.dev" + source: hosted + version: "1.19.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27 + url: "https://pub.dev" + source: hosted + version: "3.0.5" + custom_lint: + dependency: transitive + description: + name: custom_lint + sha256: "6e1ec47427ca968f22bce734d00028ae7084361999b41673291138945c5baca0" + url: "https://pub.dev" + source: hosted + version: "0.6.7" + custom_lint_builder: + dependency: "direct main" + description: + name: custom_lint_builder + sha256: ba2f90fff4eff71d202d097eb14b14f87087eaaef742e956208c0eb9d3a40a21 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "4ddbbdaa774265de44c97054dcec058a83d9081d071785ece601e348c18c267d" + url: "https://pub.dev" + source: hosted + version: "0.6.5" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab" + url: "https://pub.dev" + source: hosted + version: "2.3.7" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + glob: + dependency: "direct main" + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + url: "https://pub.dev" + source: hosted + version: "4.2.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + macros: + dependency: transitive + description: + name: macros + sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + url: "https://pub.dev" + source: hosted + version: "0.1.2-main.4" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + url: "https://pub.dev" + source: hosted + version: "0.7.3" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + uuid: + dependency: transitive + description: + name: uuid + sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 + url: "https://pub.dev" + source: hosted + version: "4.5.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.5.0 <4.0.0" diff --git a/mobile/immich_lint/pubspec.yaml b/mobile/immich_lint/pubspec.yaml new file mode 100644 index 0000000000000..9d1a3c26b3ca8 --- /dev/null +++ b/mobile/immich_lint/pubspec.yaml @@ -0,0 +1,14 @@ +name: immich_mobile_immich_lint +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + analyzer: ^6.8.0 + analyzer_plugin: ^0.11.3 + custom_lint_builder: ^0.6.4 + glob: ^2.1.2 + +dev_dependencies: + lints: ^5.0.0 diff --git a/mobile/ios/Gemfile.lock b/mobile/ios/Gemfile.lock index b41cba39e67c1..218b8c13551c4 100644 --- a/mobile/ios/Gemfile.lock +++ b/mobile/ios/Gemfile.lock @@ -169,8 +169,8 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.2.8) - strscan (>= 3.0.9) + rexml (3.3.6) + strscan rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -195,13 +195,13 @@ GEM uber (0.1.0) unicode-display_width (1.8.0) word_wrap (1.0.0) - xcodeproj (1.24.0) + xcodeproj (1.25.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) - rexml (~> 3.2.4) + rexml (>= 3.3.2, < 4.0) xcpretty (0.3.0) rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 3b361c4e1902f..6a9d34ab83bfe 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - background_downloader (0.0.1): + - Flutter - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift @@ -99,6 +101,7 @@ PODS: - Flutter DEPENDENCIES: + - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -137,6 +140,8 @@ SPEC REPOS: - Toast EXTERNAL SOURCES: + background_downloader: + :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" device_info_plus: @@ -189,6 +194,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 2d8439e36a4f3..076edc078339f 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 173; + CURRENT_PROJECT_VERSION = 179; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index b33be9a370df7..4ed247245a6d9 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.114.0 + 1.117.0 CFBundleSignature ???? CFBundleVersion - 173 + 179 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json new file mode 100644 index 0000000000000..7391713b6fb06 --- /dev/null +++ b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json @@ -0,0 +1 @@ +{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index c1740771d98c4..49cab2fad33f4 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.114.0" + version_number: "1.117.0" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart new file mode 100644 index 0000000000000..8b74b1a66fb2f --- /dev/null +++ b/mobile/lib/constants/constants.dart @@ -0,0 +1 @@ +const int noDbId = -9223372036854775808; // from Isar diff --git a/mobile/lib/constants/filters.dart b/mobile/lib/constants/filters.dart new file mode 100644 index 0000000000000..d9fa2920b7459 --- /dev/null +++ b/mobile/lib/constants/filters.dart @@ -0,0 +1,799 @@ +import 'package:flutter/material.dart'; + +List filters = [ + //Original + const ColorFilter.matrix([ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Vintage + const ColorFilter.matrix([ + 0.8, + 0.1, + 0.1, + 0, + 20, + 0.1, + 0.8, + 0.1, + 0, + 20, + 0.1, + 0.1, + 0.8, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Mood + const ColorFilter.matrix([ + 1.2, + 0.1, + 0.1, + 0, + 10, + 0.1, + 1, + 0.1, + 0, + 10, + 0.1, + 0.1, + 1, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Crisp + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Cool + const ColorFilter.matrix([ + 0.9, + 0, + 0.2, + 0, + 0, + 0, + 1, + 0.1, + 0, + 0, + 0.1, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Blush + const ColorFilter.matrix([ + 1.1, + 0.1, + 0.1, + 0, + 10, + 0.1, + 1, + 0.1, + 0, + 10, + 0.1, + 0.1, + 1, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Sunkissed + const ColorFilter.matrix([ + 1.3, + 0, + 0.1, + 0, + 15, + 0, + 1.1, + 0.1, + 0, + 10, + 0, + 0, + 0.9, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Fresh + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 20, + 0, + 1.2, + 0, + 0, + 20, + 0, + 0, + 1.1, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Classic + const ColorFilter.matrix([ + 1.1, + 0, + -0.1, + 0, + 10, + -0.1, + 1.1, + 0.1, + 0, + 5, + 0, + -0.1, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lomo-ish + const ColorFilter.matrix([ + 1.5, + 0, + 0.1, + 0, + 0, + 0, + 1.45, + 0, + 0, + 0, + 0.1, + 0, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Nashville + const ColorFilter.matrix([ + 1.2, + 0.15, + -0.15, + 0, + 15, + 0.1, + 1.1, + 0.1, + 0, + 10, + -0.05, + 0.2, + 1.25, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Valencia + const ColorFilter.matrix([ + 1.15, + 0.1, + 0.1, + 0, + 20, + 0.1, + 1.1, + 0, + 0, + 10, + 0.1, + 0.1, + 1.2, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Clarendon + const ColorFilter.matrix([ + 1.2, + 0, + 0, + 0, + 10, + 0, + 1.25, + 0, + 0, + 10, + 0, + 0, + 1.3, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Moon + const ColorFilter.matrix([ + 0.33, + 0.33, + 0.33, + 0, + 0, + 0.33, + 0.33, + 0.33, + 0, + 0, + 0.33, + 0.33, + 0.33, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Willow + const ColorFilter.matrix([ + 0.5, + 0.5, + 0.5, + 0, + 20, + 0.5, + 0.5, + 0.5, + 0, + 20, + 0.5, + 0.5, + 0.5, + 0, + 20, + 0, + 0, + 0, + 1, + 0, + ]), + //Kodak + const ColorFilter.matrix([ + 1.3, + 0.1, + -0.1, + 0, + 10, + 0, + 1.25, + 0.1, + 0, + 10, + 0, + -0.1, + 1.1, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + ]), + //Frost + const ColorFilter.matrix([ + 0.8, + 0.2, + 0.1, + 0, + 0, + 0.2, + 1.1, + 0.1, + 0, + 0, + 0.1, + 0.1, + 1.2, + 0, + 10, + 0, + 0, + 0, + 1, + 0, + ]), + //Night Vision + const ColorFilter.matrix([ + 0.1, + 0.95, + 0.2, + 0, + 0, + 0.1, + 1.5, + 0.1, + 0, + 0, + 0.2, + 0.7, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Sunset + const ColorFilter.matrix([ + 1.5, + 0.2, + 0, + 0, + 0, + 0.1, + 0.9, + 0.1, + 0, + 0, + -0.1, + -0.2, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Noir + const ColorFilter.matrix([ + 1.3, + -0.3, + 0.1, + 0, + 0, + -0.1, + 1.2, + -0.1, + 0, + 0, + 0.1, + -0.2, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Dreamy + const ColorFilter.matrix([ + 1.1, + 0.1, + 0.1, + 0, + 0, + 0.1, + 1.1, + 0.1, + 0, + 0, + 0.1, + 0.1, + 1.1, + 0, + 15, + 0, + 0, + 0, + 1, + 0, + ]), + //Sepia + const ColorFilter.matrix([ + 0.393, + 0.769, + 0.189, + 0, + 0, + 0.349, + 0.686, + 0.168, + 0, + 0, + 0.272, + 0.534, + 0.131, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Radium + const ColorFilter.matrix([ + 1.438, + -0.062, + -0.062, + 0, + 0, + -0.122, + 1.378, + -0.122, + 0, + 0, + -0.016, + -0.016, + 1.483, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Aqua + const ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.7873, + 0.2848, + 0.9278, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Purple Haze + const ColorFilter.matrix([ + 1.3, + 0, + 1.2, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0.2, + 0, + 1.3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lemonade + const ColorFilter.matrix([ + 1.2, + 0.1, + 0, + 0, + 0, + 0, + 1.1, + 0.2, + 0, + 0, + 0.1, + 0, + 0.7, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Caramel + const ColorFilter.matrix([ + 1.6, + 0.2, + 0, + 0, + 0, + 0.1, + 1.3, + 0.1, + 0, + 0, + 0, + 0.1, + 0.9, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Peachy + const ColorFilter.matrix([ + 1.3, + 0.5, + 0, + 0, + 0, + 0.2, + 1.1, + 0.3, + 0, + 0, + 0.1, + 0.1, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Neon + const ColorFilter.matrix([ + 1, + 0, + 1, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Cold Morning + const ColorFilter.matrix([ + 0.9, + 0.1, + 0.2, + 0, + 0, + 0, + 1, + 0.1, + 0, + 0, + 0.1, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Lush + const ColorFilter.matrix([ + 0.9, + 0.2, + 0, + 0, + 0, + 0, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Urban Neon + const ColorFilter.matrix([ + 1.1, + 0, + 0.3, + 0, + 0, + 0, + 0.9, + 0.3, + 0, + 0, + 0.3, + 0.1, + 1.2, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + //Monochrome + const ColorFilter.matrix([ + 0.6, + 0.2, + 0.2, + 0, + 0, + 0.2, + 0.6, + 0.2, + 0, + 0, + 0.2, + 0.2, + 0.7, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), +]; + +const List filterNames = [ + 'Original', + 'Vintage', + 'Mood', + 'Crisp', + 'Cool', + 'Blush', + 'Sunkissed', + 'Fresh', + 'Classic', + 'Lomo-ish', + 'Nashville', + 'Valencia', + 'Clarendon', + 'Moon', + 'Willow', + 'Kodak', + 'Frost', + 'Night Vision', + 'Sunset', + 'Noir', + 'Dreamy', + 'Sepia', + 'Radium', + 'Aqua', + 'Purple Haze', + 'Lemonade', + 'Caramel', + 'Peachy', + 'Neon', + 'Cold Morning', + 'Lush', + 'Urban Neon', + 'Monochrome', +]; diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index c05b849dcd26d..6331c4b9f06d0 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -1,11 +1,11 @@ import 'package:flutter/foundation.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:isar/isar.dart'; +// ignore: implementation_imports +import 'package:isar/src/common/isar_links_common.dart'; import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart'; part 'album.entity.g.dart'; @@ -25,6 +25,7 @@ class Album { required this.activityEnabled, }); + // fields stored in DB Id id = Isar.autoIncrement; @Index(unique: false, replace: false, type: IndexType.hash) String? remoteId; @@ -43,6 +44,17 @@ class Album { final IsarLinks sharedUsers = IsarLinks(); final IsarLinks assets = IsarLinks(); + // transient fields + @ignore + bool isAll = false; + + @ignore + String? remoteThumbnailAssetId; + + @ignore + int remoteAssetCount = 0; + + // getters @ignore bool get isRemote => remoteId != null; @@ -70,6 +82,21 @@ class Album { return name.join(' '); } + @ignore + String get eTagKeyAssetCount => "device-album-$localId-asset-count"; + + // the following getter are needed because Isar links do not make data + // accessible in an object freshly created (not loaded from DB) + + @ignore + Iterable get remoteUsers => sharedUsers.isEmpty + ? (sharedUsers as IsarLinksCommon).addedObjects + : sharedUsers; + + @ignore + Iterable get remoteAssets => + assets.isEmpty ? (assets as IsarLinksCommon).addedObjects : assets; + @override bool operator ==(other) { if (other is! Album) return false; @@ -112,19 +139,6 @@ class Album { sharedUsers.length.hashCode ^ assets.length.hashCode; - static Album local(AssetPathEntity ape) { - final Album a = Album( - name: ape.name, - createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(), - modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(), - shared: false, - activityEnabled: false, - ); - a.owner.value = Store.get(StoreKey.currentUser); - a.localId = ape.id; - return a; - } - static Future remote(AlbumResponseDto dto) async { final Isar db = Isar.getInstance()!; final Album a = Album( @@ -138,6 +152,7 @@ class Album { endDate: dto.endDate, activityEnabled: dto.isActivityEnabled, ); + a.remoteAssetCount = dto.assetCount; a.owner.value = await db.users.getById(dto.ownerId); if (dto.albumThumbnailAssetId != null) { a.thumbnail.value = await db.assets @@ -164,19 +179,12 @@ class Album { } extension AssetsHelper on IsarCollection { - Future store(Album a) async { + Future store(Album a) async { await put(a); await a.owner.save(); await a.thumbnail.save(); await a.sharedUsers.save(); await a.assets.save(); + return a; } } - -extension AlbumResponseDtoHelper on AlbumResponseDto { - List getAssets() => assets.map(Asset.remote).toList(); -} - -extension AssetPathEntityHelper on AssetPathEntity { - String get eTagKeyAssetCount => "device-album-$id-asset-count"; -} diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 97e10b3d200fe..8e2d9c84d5d1e 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,11 +1,10 @@ import 'dart:convert'; import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/hash.dart'; import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show AssetEntity; import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:path/path.dart' as p; @@ -23,8 +22,12 @@ class Asset { durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, type = remote.type.toAssetType(), fileName = remote.originalFileName, - height = remote.exifInfo?.exifImageHeight?.toInt(), - width = remote.exifInfo?.exifImageWidth?.toInt(), + height = isFlipped(remote) + ? remote.exifInfo?.exifImageWidth?.toInt() + : remote.exifInfo?.exifImageHeight?.toInt(), + width = isFlipped(remote) + ? remote.exifInfo?.exifImageHeight?.toInt() + : remote.exifInfo?.exifImageWidth?.toInt(), livePhotoVideoId = remote.livePhotoVideoId, ownerId = fastHash(remote.ownerId), exifInfo = @@ -42,33 +45,6 @@ class Asset { stackId = remote.stack?.id, thumbhash = remote.thumbhash; - Asset.local(AssetEntity local, List hash) - : localId = local.id, - checksum = base64.encode(hash), - durationInSeconds = local.duration, - type = AssetType.values[local.typeInt], - height = local.height, - width = local.width, - fileName = local.title!, - ownerId = Store.get(StoreKey.currentUser).isarId, - fileModifiedAt = local.modifiedDateTime, - updatedAt = local.modifiedDateTime, - isFavorite = local.isFavorite, - isArchived = false, - isTrashed = false, - isOffline = false, - stackCount = 0, - fileCreatedAt = local.createDateTime { - if (fileCreatedAt.year == 1970) { - fileCreatedAt = fileModifiedAt; - } - if (local.latitude != null) { - exifInfo = ExifInfo(lat: local.latitude, long: local.longitude); - } - _local = local; - assert(hash.length == 20, "invalid SHA1 hash"); - } - Asset({ this.id = Isar.autoIncrement, required this.checksum, @@ -115,6 +91,8 @@ class Asset { return _local; } + set local(AssetEntity? assetEntity) => _local = assetEntity; + Id id = Isar.autoIncrement; /// stores the raw SHA1 bytes as a base64 String @@ -210,6 +188,10 @@ class Asset { @ignore Duration get duration => Duration(seconds: durationInSeconds); + // ignore: invalid_annotation_target + @ignore + set byteHash(List hash) => checksum = base64.encode(hash); + @override bool operator ==(other) { if (other is! Asset) return false; @@ -529,3 +511,20 @@ extension AssetsHelper on IsarCollection { return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); } } + +/// Returns `true` if this [int] is flipped 90° clockwise +bool isRotated90CW(int orientation) { + return [7, 8, -90].contains(orientation); +} + +/// Returns `true` if this [int] is flipped 270° clockwise +bool isRotated270CW(int orientation) { + return [5, 6, 90].contains(orientation); +} + +/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise +bool isFlipped(AssetResponseDto response) { + final int orientation = response.exifInfo?.orientation?.toInt() ?? 0; + return orientation != 0 && + (isRotated90CW(orientation) || isRotated270CW(orientation)); +} diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 23bf23604635d..8be636efb659b 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -57,69 +57,64 @@ const AssetSchema = CollectionSchema( name: r'isFavorite', type: IsarType.bool, ), - r'isOffline': PropertySchema( - id: 8, - name: r'isOffline', - type: IsarType.bool, - ), r'isTrashed': PropertySchema( - id: 9, + id: 8, name: r'isTrashed', type: IsarType.bool, ), r'livePhotoVideoId': PropertySchema( - id: 10, + id: 9, name: r'livePhotoVideoId', type: IsarType.string, ), r'localId': PropertySchema( - id: 11, + id: 10, name: r'localId', type: IsarType.string, ), r'ownerId': PropertySchema( - id: 12, + id: 11, name: r'ownerId', type: IsarType.long, ), r'remoteId': PropertySchema( - id: 13, + id: 12, name: r'remoteId', type: IsarType.string, ), r'stackCount': PropertySchema( - id: 14, + id: 13, name: r'stackCount', type: IsarType.long, ), r'stackId': PropertySchema( - id: 15, + id: 14, name: r'stackId', type: IsarType.string, ), r'stackPrimaryAssetId': PropertySchema( - id: 16, + id: 15, name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 17, + id: 16, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 18, + id: 17, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 19, + id: 18, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 20, + id: 19, name: r'width', type: IsarType.int, ) @@ -244,19 +239,18 @@ void _assetSerialize( writer.writeInt(offsets[5], object.height); writer.writeBool(offsets[6], object.isArchived); writer.writeBool(offsets[7], object.isFavorite); - writer.writeBool(offsets[8], object.isOffline); - writer.writeBool(offsets[9], object.isTrashed); - writer.writeString(offsets[10], object.livePhotoVideoId); - writer.writeString(offsets[11], object.localId); - writer.writeLong(offsets[12], object.ownerId); - writer.writeString(offsets[13], object.remoteId); - writer.writeLong(offsets[14], object.stackCount); - writer.writeString(offsets[15], object.stackId); - writer.writeString(offsets[16], object.stackPrimaryAssetId); - writer.writeString(offsets[17], object.thumbhash); - writer.writeByte(offsets[18], object.type.index); - writer.writeDateTime(offsets[19], object.updatedAt); - writer.writeInt(offsets[20], object.width); + writer.writeBool(offsets[8], object.isTrashed); + writer.writeString(offsets[9], object.livePhotoVideoId); + writer.writeString(offsets[10], object.localId); + writer.writeLong(offsets[11], object.ownerId); + writer.writeString(offsets[12], object.remoteId); + writer.writeLong(offsets[13], object.stackCount); + writer.writeString(offsets[14], object.stackId); + writer.writeString(offsets[15], object.stackPrimaryAssetId); + writer.writeString(offsets[16], object.thumbhash); + writer.writeByte(offsets[17], object.type.index); + writer.writeDateTime(offsets[18], object.updatedAt); + writer.writeInt(offsets[19], object.width); } Asset _assetDeserialize( @@ -275,20 +269,19 @@ Asset _assetDeserialize( id: id, isArchived: reader.readBoolOrNull(offsets[6]) ?? false, isFavorite: reader.readBoolOrNull(offsets[7]) ?? false, - isOffline: reader.readBoolOrNull(offsets[8]) ?? false, - isTrashed: reader.readBoolOrNull(offsets[9]) ?? false, - livePhotoVideoId: reader.readStringOrNull(offsets[10]), - localId: reader.readStringOrNull(offsets[11]), - ownerId: reader.readLong(offsets[12]), - remoteId: reader.readStringOrNull(offsets[13]), - stackCount: reader.readLongOrNull(offsets[14]) ?? 0, - stackId: reader.readStringOrNull(offsets[15]), - stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), - thumbhash: reader.readStringOrNull(offsets[17]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? + isTrashed: reader.readBoolOrNull(offsets[8]) ?? false, + livePhotoVideoId: reader.readStringOrNull(offsets[9]), + localId: reader.readStringOrNull(offsets[10]), + ownerId: reader.readLong(offsets[11]), + remoteId: reader.readStringOrNull(offsets[12]), + stackCount: reader.readLongOrNull(offsets[13]) ?? 0, + stackId: reader.readStringOrNull(offsets[14]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[15]), + thumbhash: reader.readStringOrNull(offsets[16]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[19]), - width: reader.readIntOrNull(offsets[20]), + updatedAt: reader.readDateTime(offsets[18]), + width: reader.readIntOrNull(offsets[19]), ); return object; } @@ -319,29 +312,27 @@ P _assetDeserializeProp

( case 8: return (reader.readBoolOrNull(offset) ?? false) as P; case 9: - return (reader.readBoolOrNull(offset) ?? false) as P; + return (reader.readStringOrNull(offset)) as P; case 10: return (reader.readStringOrNull(offset)) as P; case 11: - return (reader.readStringOrNull(offset)) as P; - case 12: return (reader.readLong(offset)) as P; - case 13: + case 12: return (reader.readStringOrNull(offset)) as P; - case 14: + case 13: return (reader.readLongOrNull(offset) ?? 0) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: - return (reader.readStringOrNull(offset)) as P; - case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 19: + case 18: return (reader.readDateTime(offset)) as P; - case 20: + case 19: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1362,16 +1353,6 @@ extension AssetQueryFilter on QueryBuilder { }); } - QueryBuilder isOfflineEqualTo( - bool value) { - return QueryBuilder.apply(this, (query) { - return query.addFilterCondition(FilterCondition.equalTo( - property: r'isOffline', - value: value, - )); - }); - } - QueryBuilder isTrashedEqualTo( bool value) { return QueryBuilder.apply(this, (query) { @@ -2647,18 +2628,6 @@ extension AssetQuerySortBy on QueryBuilder { }); } - QueryBuilder sortByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder sortByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder sortByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -2913,18 +2882,6 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } - QueryBuilder thenByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.asc); - }); - } - - QueryBuilder thenByIsOfflineDesc() { - return QueryBuilder.apply(this, (query) { - return query.addSortBy(r'isOffline', Sort.desc); - }); - } - QueryBuilder thenByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -3121,12 +3078,6 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } - QueryBuilder distinctByIsOffline() { - return QueryBuilder.apply(this, (query) { - return query.addDistinctBy(r'isOffline'); - }); - } - QueryBuilder distinctByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'isTrashed'); @@ -3263,12 +3214,6 @@ extension AssetQueryProperty on QueryBuilder { }); } - QueryBuilder isOfflineProperty() { - return QueryBuilder.apply(this, (query) { - return query.addPropertyName(r'isOffline'); - }); - } - QueryBuilder isTrashedProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'isTrashed'); diff --git a/mobile/lib/extensions/collection_extensions.dart b/mobile/lib/extensions/collection_extensions.dart index 769bec472b210..d27c9e9500dd2 100644 --- a/mobile/lib/extensions/collection_extensions.dart +++ b/mobile/lib/extensions/collection_extensions.dart @@ -70,18 +70,6 @@ extension AssetListExtension on Iterable { } return this; } - - /// Filters out offline assets and returns those that are still accessible by the Immich server - Iterable nonOfflineOnly({ - void Function()? errorCallback, - }) { - final bool onlyLive = every((e) => !e.isOffline); - if (!onlyLive) { - if (errorCallback != null) errorCallback(); - return where((a) => !a.isOffline); - } - return this; - } } extension SortedByProperty on Iterable { diff --git a/mobile/lib/interfaces/activity_api.interface.dart b/mobile/lib/interfaces/activity_api.interface.dart new file mode 100644 index 0000000000000..99aef6f4d4668 --- /dev/null +++ b/mobile/lib/interfaces/activity_api.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/models/activities/activity.model.dart'; + +abstract interface class IActivityApiRepository { + Future> getAll( + String albumId, { + String? assetId, + }); + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }); + Future delete(String id); + Future getStats(String albumId, {String? assetId}); +} diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart new file mode 100644 index 0000000000000..ba188f127009a --- /dev/null +++ b/mobile/lib/interfaces/album.interface.dart @@ -0,0 +1,43 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IAlbumRepository implements IDatabaseRepository { + Future create(Album album); + + Future get(int id); + + Future getByName( + String name, { + bool? shared, + bool? remote, + }); + + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }); + + Future update(Album album); + + Future delete(int albumId); + + Future deleteAllLocal(); + + Future count({bool? local}); + + Future addUsers(Album album, List users); + + Future removeUsers(Album album, List users); + + Future addAssets(Album album, List assets); + + Future removeAssets(Album album, List assets); + + Future recalculateMetadata(Album album); +} + +enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/interfaces/album_api.interface.dart b/mobile/lib/interfaces/album_api.interface.dart new file mode 100644 index 0000000000000..33b589841fdc3 --- /dev/null +++ b/mobile/lib/interfaces/album_api.interface.dart @@ -0,0 +1,40 @@ +import 'package:immich_mobile/entities/album.entity.dart'; + +abstract interface class IAlbumApiRepository { + Future get(String id); + + Future> getAll({bool? shared}); + + Future create( + String name, { + required Iterable assetIds, + Iterable sharedUserIds = const [], + }); + + Future update( + String albumId, { + String? name, + String? thumbnailAssetId, + String? description, + bool? activityEnabled, + }); + + Future delete(String albumId); + + Future<({List added, List duplicates})> addAssets( + String albumId, + Iterable assetIds, + ); + + Future<({List removed, List failed})> removeAssets( + String albumId, + Iterable assetIds, + ); + + Future addUsers( + String albumId, + Iterable userIds, + ); + + Future removeUser(String albumId, {required String userId}); +} diff --git a/mobile/lib/interfaces/album_media.interface.dart b/mobile/lib/interfaces/album_media.interface.dart new file mode 100644 index 0000000000000..fd5f3c8af1063 --- /dev/null +++ b/mobile/lib/interfaces/album_media.interface.dart @@ -0,0 +1,21 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAlbumMediaRepository { + Future> getAll(); + + Future> getAssetIds(String albumId); + + Future getAssetCount(String albumId); + + Future> getAssets( + String albumId, { + int start = 0, + int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, + bool orderByModificationDate = false, + }); + + Future get(String id); +} diff --git a/mobile/lib/interfaces/asset.interface.dart b/mobile/lib/interfaces/asset.interface.dart new file mode 100644 index 0000000000000..5aec594eb1148 --- /dev/null +++ b/mobile/lib/interfaces/asset.interface.dart @@ -0,0 +1,62 @@ +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IAssetRepository implements IDatabaseRepository { + Future getByRemoteId(String id); + + Future getByOwnerIdChecksum(int ownerId, String checksum); + + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }); + + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ); + + Future> getAll({ + required int ownerId, + AssetState? state, + AssetSort? sortBy, + int? limit, + }); + + Future> getAllLocal(); + + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }); + + Future update(Asset asset); + + Future> updateAll(List assets); + + Future deleteAllByRemoteId(List ids, {AssetState? state}); + + Future deleteById(List ids); + + Future> getMatches({ + required List assets, + required int ownerId, + AssetState? state, + int limit = 100, + }); + + Future> getDeviceAssetsById(List ids); + + Future upsertDeviceAssets(List deviceAssets); + + Future upsertDuplicatedAssets(Iterable duplicatedAssets); + + Future> getAllDuplicatedAssetIds(); +} + +enum AssetSort { checksum, ownerIdChecksum } diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart new file mode 100644 index 0000000000000..fe3320c9bb101 --- /dev/null +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -0,0 +1,18 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAssetApiRepository { + // Future get(String id); + + // Future> getAll(); + + // Future create(Asset asset); + + Future update( + String id, { + String? description, + }); + + // Future delete(String id); + + Future> search({List personIds = const []}); +} diff --git a/mobile/lib/interfaces/asset_media.interface.dart b/mobile/lib/interfaces/asset_media.interface.dart new file mode 100644 index 0000000000000..2606d5c23c518 --- /dev/null +++ b/mobile/lib/interfaces/asset_media.interface.dart @@ -0,0 +1,10 @@ +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IAssetMediaRepository { + Future> deleteAll(List ids); + + Future get(String id); + + /// Obtaining the correct original filename of the asset + Future getOriginalFilename(String id); +} diff --git a/mobile/lib/interfaces/backup.interface.dart b/mobile/lib/interfaces/backup.interface.dart new file mode 100644 index 0000000000000..c32199a58f476 --- /dev/null +++ b/mobile/lib/interfaces/backup.interface.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IBackupRepository implements IDatabaseRepository { + Future> getAll({BackupAlbumSort? sort}); + + Future> getIdsBySelection(BackupSelection backup); + + Future> getAllBySelection(BackupSelection backup); + + Future updateAll(List backupAlbums); + + Future deleteAll(List ids); +} + +enum BackupAlbumSort { id } diff --git a/mobile/lib/interfaces/database.interface.dart b/mobile/lib/interfaces/database.interface.dart new file mode 100644 index 0000000000000..5645d15c47beb --- /dev/null +++ b/mobile/lib/interfaces/database.interface.dart @@ -0,0 +1,3 @@ +abstract interface class IDatabaseRepository { + Future transaction(Future Function() callback); +} diff --git a/mobile/lib/interfaces/download.interface.dart b/mobile/lib/interfaces/download.interface.dart new file mode 100644 index 0000000000000..dc4f0f57f8c44 --- /dev/null +++ b/mobile/lib/interfaces/download.interface.dart @@ -0,0 +1,14 @@ +import 'package:background_downloader/background_downloader.dart'; + +abstract interface class IDownloadRepository { + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + Future> getLiveVideoTasks(); + Future download(DownloadTask task); + Future cancel(String id); + Future deleteAllTrackingRecords(); + Future deleteRecordsWithIds(List id); +} diff --git a/mobile/lib/interfaces/etag.interface.dart b/mobile/lib/interfaces/etag.interface.dart new file mode 100644 index 0000000000000..e567235d1bb2d --- /dev/null +++ b/mobile/lib/interfaces/etag.interface.dart @@ -0,0 +1,14 @@ +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IETagRepository implements IDatabaseRepository { + Future get(int id); + + Future getById(String id); + + Future> getAllIds(); + + Future upsertAll(List etags); + + Future deleteByIds(List ids); +} diff --git a/mobile/lib/interfaces/exif_info.interface.dart b/mobile/lib/interfaces/exif_info.interface.dart new file mode 100644 index 0000000000000..86608c26d0cf8 --- /dev/null +++ b/mobile/lib/interfaces/exif_info.interface.dart @@ -0,0 +1,12 @@ +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IExifInfoRepository implements IDatabaseRepository { + Future get(int id); + + Future update(ExifInfo exifInfo); + + Future> updateAll(List exifInfos); + + Future delete(int id); +} diff --git a/mobile/lib/interfaces/file_media.interface.dart b/mobile/lib/interfaces/file_media.interface.dart new file mode 100644 index 0000000000000..c898183d79229 --- /dev/null +++ b/mobile/lib/interfaces/file_media.interface.dart @@ -0,0 +1,30 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:immich_mobile/entities/asset.entity.dart'; + +abstract interface class IFileMediaRepository { + Future saveImage( + Uint8List data, { + required String title, + String? relativePath, + }); + + Future saveVideo( + File file, { + required String title, + String? relativePath, + }); + + Future saveLivePhoto({ + required File image, + required File video, + required String title, + }); + + Future clearFileCache(); + + Future enableBackgroundAccess(); + + Future requestExtendedPermissions(); +} diff --git a/mobile/lib/interfaces/partner_api.interface.dart b/mobile/lib/interfaces/partner_api.interface.dart new file mode 100644 index 0000000000000..bca1baf66d251 --- /dev/null +++ b/mobile/lib/interfaces/partner_api.interface.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IPartnerApiRepository { + Future> getAll(Direction direction); + Future create(String id); + Future update(String id, {required bool inTimeline}); + Future delete(String id); +} + +enum Direction { + sharedWithMe, + sharedByMe, +} diff --git a/mobile/lib/interfaces/person_api.interface.dart b/mobile/lib/interfaces/person_api.interface.dart new file mode 100644 index 0000000000000..b2fa28df8cc19 --- /dev/null +++ b/mobile/lib/interfaces/person_api.interface.dart @@ -0,0 +1,22 @@ +abstract interface class IPersonApiRepository { + Future> getAll(); + Future update(String id, {String? name}); +} + +class Person { + Person({ + required this.id, + required this.isHidden, + required this.name, + required this.thumbnailPath, + this.birthDate, + this.updatedAt, + }); + + final String id; + final DateTime? birthDate; + final bool isHidden; + final String name; + final String thumbnailPath; + final DateTime? updatedAt; +} diff --git a/mobile/lib/interfaces/user.interface.dart b/mobile/lib/interfaces/user.interface.dart new file mode 100644 index 0000000000000..e6175a7dc9906 --- /dev/null +++ b/mobile/lib/interfaces/user.interface.dart @@ -0,0 +1,23 @@ +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/database.interface.dart'; + +abstract interface class IUserRepository implements IDatabaseRepository { + Future get(String id); + + Future> getByIds(List ids); + + Future> getAll({bool self = true, UserSort? sortBy}); + + /// Returns all users whose assets can be accessed (self+partners) + Future> getAllAccessible(); + + Future> upsertAll(List users); + + Future update(User user); + + Future deleteById(List ids); + + Future me(); +} + +enum UserSort { id } diff --git a/mobile/lib/interfaces/user_api.interface.dart b/mobile/lib/interfaces/user_api.interface.dart new file mode 100644 index 0000000000000..67ac3c08831be --- /dev/null +++ b/mobile/lib/interfaces/user_api.interface.dart @@ -0,0 +1,11 @@ +import 'dart:typed_data'; + +import 'package:immich_mobile/entities/user.entity.dart'; + +abstract interface class IUserApiRepository { + Future> getAll(); + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }); +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index dc1df746cb964..40eda30204e01 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/download.dart'; import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/services/background.service.dart'; @@ -72,7 +74,6 @@ Future initApp() async { var log = Logger("ImmichErrorLogger"); FlutterError.onError = (details) { - debugPrint("FlutterError - Catch all: $details"); FlutterError.presentError(details); log.severe( 'FlutterError - Catch all', @@ -82,11 +83,29 @@ Future initApp() async { }; PlatformDispatcher.instance.onError = (error, stack) { + debugPrint("FlutterError - Catch all: $error"); log.severe('PlatformDispatcher - Catch all', error, stack); return true; }; initializeTimeZones(); + + FileDownloader().configureNotification( + running: TaskNotification( + 'downloading_media'.tr(), + 'file: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + 'file: {filename}', + ), + progressBar: true, + ); + + FileDownloader().trackTasksInGroup( + downloadGroupLivePhoto, + markDownloadedComplete: false, + ); } Future loadDb() async { @@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState @override Widget build(BuildContext context) { - var router = ref.watch(appRouterProvider); - var immichTheme = ref.watch(immichThemeProvider); + final router = ref.watch(appRouterProvider); + final immichTheme = ref.watch(immichThemeProvider); return MaterialApp( localizationsDelegates: context.localizationDelegates, diff --git a/mobile/lib/models/activities/activity.model.dart b/mobile/lib/models/activities/activity.model.dart index 6adb80dca9233..4702753f41cdd 100644 --- a/mobile/lib/models/activities/activity.model.dart +++ b/mobile/lib/models/activities/activity.model.dart @@ -1,5 +1,4 @@ import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:openapi/api.dart'; enum ActivityType { comment, like } @@ -38,16 +37,6 @@ class Activity { ); } - Activity.fromDto(ActivityResponseDto dto) - : id = dto.id, - assetId = dto.assetId, - comment = dto.comment, - createdAt = dto.createdAt, - type = dto.type == ReactionType.comment - ? ActivityType.comment - : ActivityType.like, - user = User.fromSimpleUserDto(dto.user); - @override String toString() { return 'Activity(id: $id, assetId: $assetId, comment: $comment, createdAt: $createdAt, type: $type, user: $user)'; @@ -75,3 +64,9 @@ class Activity { user.hashCode; } } + +class ActivityStats { + final int comments; + + const ActivityStats({required this.comments}); +} diff --git a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart b/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart deleted file mode 100644 index 0a354781f81c3..0000000000000 --- a/mobile/lib/models/asset_viewer/asset_viewer_page_state.model.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:convert'; - -enum DownloadAssetStatus { idle, loading, success, error } - -class AssetViewerPageState { - // enum - final DownloadAssetStatus downloadAssetStatus; - - AssetViewerPageState({ - required this.downloadAssetStatus, - }); - - AssetViewerPageState copyWith({ - DownloadAssetStatus? downloadAssetStatus, - }) { - return AssetViewerPageState( - downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus, - ); - } - - Map toMap() { - final result = {}; - - result.addAll({'downloadAssetStatus': downloadAssetStatus.index}); - - return result; - } - - factory AssetViewerPageState.fromMap(Map map) { - return AssetViewerPageState( - downloadAssetStatus: - DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0], - ); - } - - String toJson() => json.encode(toMap()); - - factory AssetViewerPageState.fromJson(String source) => - AssetViewerPageState.fromMap(json.decode(source)); - - @override - String toString() => - 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is AssetViewerPageState && - other.downloadAssetStatus == downloadAssetStatus; - } - - @override - int get hashCode => downloadAssetStatus.hashCode; -} diff --git a/mobile/lib/models/backup/available_album.model.dart b/mobile/lib/models/backup/available_album.model.dart index 0b428eea0fea6..59c57582ce430 100644 --- a/mobile/lib/models/backup/available_album.model.dart +++ b/mobile/lib/models/backup/available_album.model.dart @@ -1,45 +1,47 @@ import 'dart:typed_data'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; class AvailableAlbum { - final AssetPathEntity albumEntity; + final Album album; + final int assetCount; final DateTime? lastBackup; AvailableAlbum({ - required this.albumEntity, + required this.album, + required this.assetCount, this.lastBackup, }); AvailableAlbum copyWith({ - AssetPathEntity? albumEntity, + Album? album, + int? assetCount, DateTime? lastBackup, Uint8List? thumbnailData, }) { return AvailableAlbum( - albumEntity: albumEntity ?? this.albumEntity, + album: album ?? this.album, + assetCount: assetCount ?? this.assetCount, lastBackup: lastBackup ?? this.lastBackup, ); } - String get name => albumEntity.name; + String get name => album.name; - Future get assetCount => albumEntity.assetCountAsync; + String get id => album.localId!; - String get id => albumEntity.id; - - bool get isAll => albumEntity.isAll; + bool get isAll => album.isAll; @override String toString() => - 'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)'; + 'AvailableAlbum(albumEntity: $album, lastBackup: $lastBackup)'; @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is AvailableAlbum && other.albumEntity == albumEntity; + return other is AvailableAlbum && other.album == album; } @override - int get hashCode => albumEntity.hashCode; + int get hashCode => album.hashCode; } diff --git a/mobile/lib/models/backup/backup_candidate.model.dart b/mobile/lib/models/backup/backup_candidate.model.dart index 5ef15167455df..01c257dc052c0 100644 --- a/mobile/lib/models/backup/backup_candidate.model.dart +++ b/mobile/lib/models/backup/backup_candidate.model.dart @@ -1,9 +1,9 @@ -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; class BackupCandidate { BackupCandidate({required this.asset, required this.albumNames}); - AssetEntity asset; + Asset asset; List albumNames; @override diff --git a/mobile/lib/models/backup/error_upload_asset.model.dart b/mobile/lib/models/backup/error_upload_asset.model.dart index b63592eda86f3..38f241e748566 100644 --- a/mobile/lib/models/backup/error_upload_asset.model.dart +++ b/mobile/lib/models/backup/error_upload_asset.model.dart @@ -1,11 +1,11 @@ -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; class ErrorUploadAsset { final String id; final DateTime fileCreatedAt; final String fileName; final String fileType; - final AssetEntity asset; + final Asset asset; final String errorMessage; const ErrorUploadAsset({ @@ -22,7 +22,7 @@ class ErrorUploadAsset { DateTime? fileCreatedAt, String? fileName, String? fileType, - AssetEntity? asset, + Asset? asset, String? errorMessage, }) { return ErrorUploadAsset( diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart new file mode 100644 index 0000000000000..edd2fa183ec5d --- /dev/null +++ b/mobile/lib/models/download/download_state.model.dart @@ -0,0 +1,109 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; + +class DownloadInfo { + final String fileName; + final double progress; + // enum + final TaskStatus status; + + DownloadInfo({ + required this.fileName, + required this.progress, + required this.status, + }); + + DownloadInfo copyWith({ + String? fileName, + double? progress, + TaskStatus? status, + }) { + return DownloadInfo( + fileName: fileName ?? this.fileName, + progress: progress ?? this.progress, + status: status ?? this.status, + ); + } + + Map toMap() { + return { + 'fileName': fileName, + 'progress': progress, + 'status': status.index, + }; + } + + factory DownloadInfo.fromMap(Map map) { + return DownloadInfo( + fileName: map['fileName'] as String, + progress: map['progress'] as double, + status: TaskStatus.values[map['status'] as int], + ); + } + + String toJson() => json.encode(toMap()); + + factory DownloadInfo.fromJson(String source) => + DownloadInfo.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)'; + + @override + bool operator ==(covariant DownloadInfo other) { + if (identical(this, other)) return true; + + return other.fileName == fileName && + other.progress == progress && + other.status == status; + } + + @override + int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode; +} + +class DownloadState { + // enum + final TaskStatus downloadStatus; + final Map taskProgress; + final bool showProgress; + DownloadState({ + required this.downloadStatus, + required this.taskProgress, + required this.showProgress, + }); + + DownloadState copyWith({ + TaskStatus? downloadStatus, + Map? taskProgress, + bool? showProgress, + }) { + return DownloadState( + downloadStatus: downloadStatus ?? this.downloadStatus, + taskProgress: taskProgress ?? this.taskProgress, + showProgress: showProgress ?? this.showProgress, + ); + } + + @override + String toString() => + 'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)'; + + @override + bool operator ==(covariant DownloadState other) { + if (identical(this, other)) return true; + final mapEquals = const DeepCollectionEquality().equals; + + return other.downloadStatus == downloadStatus && + mapEquals(other.taskProgress, taskProgress) && + other.showProgress == showProgress; + } + + @override + int get hashCode => + downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode; +} diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart new file mode 100644 index 0000000000000..9c0c7ae4e95c7 --- /dev/null +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -0,0 +1,60 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +enum LivePhotosPart { + video, + image, +} + +class LivePhotosMetadata { + // enum + LivePhotosPart part; + + String id; + LivePhotosMetadata({ + required this.part, + required this.id, + }); + + LivePhotosMetadata copyWith({ + LivePhotosPart? part, + String? id, + }) { + return LivePhotosMetadata( + part: part ?? this.part, + id: id ?? this.id, + ); + } + + Map toMap() { + return { + 'part': part.index, + 'id': id, + }; + } + + factory LivePhotosMetadata.fromMap(Map map) { + return LivePhotosMetadata( + part: LivePhotosPart.values[map['part'] as int], + id: map['id'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory LivePhotosMetadata.fromJson(String source) => + LivePhotosMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => 'LivePhotosMetadata(part: $part, id: $id)'; + + @override + bool operator ==(covariant LivePhotosMetadata other) { + if (identical(this, other)) return true; + + return other.part == part && other.id == id; + } + + @override + int get hashCode => part.hashCode ^ id.hashCode; +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 6a7c612b1563c..297a819b6a335 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; class SearchLocationFilter { String? country; @@ -235,7 +235,7 @@ class SearchDisplayFilters { class SearchFilter { String? context; String? filename; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -258,7 +258,7 @@ class SearchFilter { SearchFilter copyWith({ String? context, String? filename, - Set? people, + Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, diff --git a/mobile/lib/models/server_info/server_config.model.dart b/mobile/lib/models/server_info/server_config.model.dart index 8936939135d26..f07ffde522f14 100644 --- a/mobile/lib/models/server_info/server_config.model.dart +++ b/mobile/lib/models/server_info/server_config.model.dart @@ -4,11 +4,15 @@ class ServerConfig { final int trashDays; final String oauthButtonText; final String externalDomain; + final String mapDarkStyleUrl; + final String mapLightStyleUrl; const ServerConfig({ required this.trashDays, required this.oauthButtonText, required this.externalDomain, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, }); ServerConfig copyWith({ @@ -20,6 +24,8 @@ class ServerConfig { trashDays: trashDays ?? this.trashDays, oauthButtonText: oauthButtonText ?? this.oauthButtonText, externalDomain: externalDomain ?? this.externalDomain, + mapDarkStyleUrl: mapDarkStyleUrl, + mapLightStyleUrl: mapLightStyleUrl, ); } @@ -30,7 +36,9 @@ class ServerConfig { ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays, oauthButtonText = dto.oauthButtonText, - externalDomain = dto.externalDomain; + externalDomain = dto.externalDomain, + mapDarkStyleUrl = dto.mapDarkStyleUrl, + mapLightStyleUrl = dto.mapLightStyleUrl; @override bool operator ==(covariant ServerConfig other) { diff --git a/mobile/lib/pages/backup/album_preview.page.dart b/mobile/lib/pages/backup/album_preview.page.dart index 5cb5d418a024a..b9fed41305dcc 100644 --- a/mobile/lib/pages/backup/album_preview.page.dart +++ b/mobile/lib/pages/backup/album_preview.page.dart @@ -1,28 +1,27 @@ -import 'dart:typed_data'; - import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; @RoutePage() class AlbumPreviewPage extends HookConsumerWidget { - final AssetPathEntity album; + final Album album; const AlbumPreviewPage({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { - final assets = useState>([]); + final assets = useState>([]); getAssetsInAlbum() async { - assets.value = await album.getAssetListRange( - start: 0, - end: await album.assetCountAsync, - ); + assets.value = await ref + .read(albumMediaRepositoryProvider) + .getAssets(album.localId!); } useEffect( @@ -68,30 +67,10 @@ class AlbumPreviewPage extends HookConsumerWidget { ), itemCount: assets.value.length, itemBuilder: (context, index) { - Future thumbData = - assets.value[index].thumbnailDataWithSize( - const ThumbnailSize(200, 200), - quality: 50, - ); - - return FutureBuilder( - future: thumbData, - builder: ((context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return Image.memory( - snapshot.data!, - width: 100, - height: 100, - fit: BoxFit.cover, - ); - } - - return const SizedBox( - width: 100, - height: 100, - child: ImmichLoadingIndicator(), - ); - }), + return ImmichThumbnail( + asset: assets.value[index], + width: 100, + height: 100, ); }, ), diff --git a/mobile/lib/pages/backup/failed_backup_status.page.dart b/mobile/lib/pages/backup/failed_backup_status.page.dart index 1c6d3a7aadef7..551555d75eaa3 100644 --- a/mobile/lib/pages/backup/failed_backup_status.page.dart +++ b/mobile/lib/pages/backup/failed_backup_status.page.dart @@ -3,9 +3,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; +import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:intl/intl.dart'; -import 'package:photo_manager/photo_manager.dart'; -import 'package:photo_manager_image_provider/photo_manager_image_provider.dart'; @RoutePage() class FailedBackupStatusPage extends HookConsumerWidget { @@ -70,11 +69,10 @@ class FailedBackupStatusPage extends HookConsumerWidget { clipBehavior: Clip.hardEdge, child: Image( fit: BoxFit.cover, - image: AssetEntityImageProvider( - errorAsset.asset, - isOriginal: false, - thumbnailSize: const ThumbnailSize.square(512), - thumbnailFormat: ThumbnailFormat.jpeg, + image: ImmichLocalThumbnailProvider( + asset: errorAsset.asset, + height: 512, + width: 512, ), ), ), diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart new file mode 100644 index 0000000000000..95cefd742af0a --- /dev/null +++ b/mobile/lib/pages/common/download_panel.dart @@ -0,0 +1,150 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; + +class DownloadPanel extends ConsumerWidget { + const DownloadPanel({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showProgress = ref.watch( + downloadStateProvider.select((state) => state.showProgress), + ); + + final tasks = ref + .watch( + downloadStateProvider.select((state) => state.taskProgress), + ) + .entries + .toList(); + + onCancelDownload(String id) { + ref.watch(downloadStateProvider.notifier).cancelDownload(id); + } + + return Positioned( + bottom: 140, + left: 16, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: showProgress + ? ConstrainedBox( + constraints: + BoxConstraints.loose(Size(context.width - 32, 300)), + child: ListView.builder( + shrinkWrap: true, + itemCount: tasks.length, + itemBuilder: (context, index) { + final task = tasks[index]; + return DownloadTaskTile( + progress: task.value.progress, + fileName: task.value.fileName, + status: task.value.status, + onCancelDownload: () => onCancelDownload(task.key), + ); + }, + ), + ) + : const SizedBox.shrink(key: ValueKey('no_progress')), + ), + ); + } +} + +class DownloadTaskTile extends StatelessWidget { + final double progress; + final String fileName; + final TaskStatus status; + final VoidCallback onCancelDownload; + + const DownloadTaskTile({ + super.key, + required this.progress, + required this.fileName, + required this.status, + required this.onCancelDownload, + }); + + @override + Widget build(BuildContext context) { + final progressPercent = (progress * 100).round(); + + getStatusText() { + switch (status) { + case TaskStatus.running: + return 'downloading'.tr(); + case TaskStatus.complete: + return 'download_complete'.tr(); + case TaskStatus.failed: + return 'download_failed'.tr(); + case TaskStatus.canceled: + return 'download_canceled'.tr(); + case TaskStatus.paused: + return 'download_paused'.tr(); + case TaskStatus.enqueued: + return 'download_enqueue'.tr(); + case TaskStatus.notFound: + return 'download_notfound'.tr(); + case TaskStatus.waitingToRetry: + return 'download_waiting_to_retry'.tr(); + } + } + + return SizedBox( + key: const ValueKey('download_progress'), + width: MediaQuery.of(context).size.width - 32, + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + minVerticalPadding: 18, + leading: const Icon(Icons.video_file_outlined), + title: Text( + getStatusText(), + style: context.textTheme.labelLarge, + ), + trailing: IconButton( + icon: Icon(Icons.close, color: context.colorScheme.onError), + onPressed: onCancelDownload, + style: ElevatedButton.styleFrom( + backgroundColor: context.colorScheme.error.withAlpha(200), + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: context.textTheme.labelMedium, + ), + Row( + children: [ + Expanded( + child: LinearProgressIndicator( + minHeight: 8.0, + value: progress, + borderRadius: + const BorderRadius.all(Radius.circular(10.0)), + ), + ), + const SizedBox(width: 8), + Text( + '$progressPercent%', + style: context.textTheme.labelSmall, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index d8ea7cd89b47f..57c75ca84df84 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,8 +8,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/video_viewer.page.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; @@ -30,7 +32,6 @@ import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; -import 'package:isar/isar.dart'; @RoutePage() // ignore: must_be_immutable @@ -73,7 +74,7 @@ class GalleryViewerPage extends HookConsumerWidget { : []; final stackElements = showStack ? [currentAsset, ...stack] : []; // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == Isar.autoIncrement; + final isFromDto = currentAsset.id == noDbId; Asset asset = stackIndex.value == -1 ? currentAsset @@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget { ], ), ), + const DownloadPanel(), ], ), ), diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 729b59ded5911..8bfb8c8bb9bf9 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -51,107 +51,109 @@ class CropImagePage extends HookWidget { ], ), backgroundColor: context.scaffoldBackgroundColor, - body: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Column( - children: [ - Container( - padding: const EdgeInsets.only(top: 20), - width: constraints.maxWidth * 0.9, - height: constraints.maxHeight * 0.6, - child: CropImage( - controller: cropController, - image: image, - gridColor: Colors.white, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Column( + children: [ + Container( + padding: const EdgeInsets.only(top: 20), + width: constraints.maxWidth * 0.9, + height: constraints.maxHeight * 0.6, + child: CropImage( + controller: cropController, + image: image, + gridColor: Colors.white, + ), ), - ), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), ), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - bottom: 10, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: Icon( - Icons.rotate_left, - color: Theme.of(context).iconTheme.color, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + bottom: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + Icons.rotate_left, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateLeft(); + }, ), - onPressed: () { - cropController.rotateLeft(); - }, - ), - IconButton( - icon: Icon( - Icons.rotate_right, - color: Theme.of(context).iconTheme.color, + IconButton( + icon: Icon( + Icons.rotate_right, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateRight(); + }, ), - onPressed: () { - cropController.rotateRight(); - }, + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: null, + label: 'Free', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 1.0, + label: '1:1', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 16.0 / 9.0, + label: '16:9', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 3.0 / 2.0, + label: '3:2', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 7.0 / 5.0, + label: '7:5', ), ], ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: null, - label: 'Free', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 1.0, - label: '1:1', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 16.0 / 9.0, - label: '16:9', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 3.0 / 2.0, - label: '3:2', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 7.0 / 5.0, - label: '7:5', - ), - ], - ), - ], + ], + ), ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ); } diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index c81e84877b208..32d3aa6ba9021 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:typed_data'; import 'dart:async'; import 'dart:ui'; @@ -8,11 +7,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/common/immich_image.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:path/path.dart' as p; @@ -67,10 +65,10 @@ class EditImagePage extends ConsumerWidget { ) async { try { final Uint8List imageData = await _imageToUint8List(image); - await PhotoManager.editor.saveImage( - imageData, - title: "${p.withoutExtension(asset.fileName)}_edited.jpg", - ); + await ref.read(fileMediaRepositoryProvider).saveImage( + imageData, + title: "${p.withoutExtension(asset.fileName)}_edited.jpg", + ); await ref.read(albumProvider.notifier).getDeviceAlbums(); Navigator.of(context).popUntil((route) => route.isFirst); ImmichToast.show( @@ -91,9 +89,6 @@ class EditImagePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final Image imageWidget = - Image(image: ImmichImage.imageProvider(asset: asset)); - return Scaffold( appBar: AppBar( title: Text("edit_image_title".tr()), @@ -157,24 +152,48 @@ class EditImagePage extends ConsumerWidget { color: context.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(30), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - IconButton( - icon: Icon( - Platform.isAndroid - ? Icons.crop_rotate_rounded - : Icons.crop_rotate_rounded, - color: Theme.of(context).iconTheme.color, - size: 25, - ), - onPressed: () { - context.pushRoute( - CropImageRoute(asset: asset, image: imageWidget), - ); - }, + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + Icons.crop_rotate_rounded, + color: Theme.of(context).iconTheme.color, + size: 25, + ), + onPressed: () { + context.pushRoute( + CropImageRoute(asset: asset, image: image), + ); + }, + ), + Text("crop".tr(), style: context.textTheme.displayMedium), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + Icons.filter, + color: Theme.of(context).iconTheme.color, + size: 25, + ), + onPressed: () { + context.pushRoute( + FilterImageRoute( + asset: asset, + image: image, + ), + ); + }, + ), + Text("filter".tr(), style: context.textTheme.displayMedium), + ], ), - Text("crop".tr(), style: context.textTheme.displayMedium), ], ), ), diff --git a/mobile/lib/pages/editing/filter.page.dart b/mobile/lib/pages/editing/filter.page.dart new file mode 100644 index 0000000000000..da8ba74891595 --- /dev/null +++ b/mobile/lib/pages/editing/filter.page.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/constants/filters.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:immich_mobile/routing/router.dart'; + +/// A widget for filtering an image. +/// This widget uses [HookWidget] to manage its lifecycle and state. It allows +/// users to add filters to an image and then navigate to the [EditImagePage] with the +/// final composition.' +@RoutePage() +class FilterImagePage extends HookWidget { + final Image image; + final Asset asset; + + const FilterImagePage({ + super.key, + required this.image, + required this.asset, + }); + + @override + Widget build(BuildContext context) { + final colorFilter = useState(filters[0]); + final selectedFilterIndex = useState(0); + + Future createFilteredImage( + ui.Image inputImage, + ColorFilter filter, + ) { + final completer = Completer(); + final size = + Size(inputImage.width.toDouble(), inputImage.height.toDouble()); + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + final paint = Paint()..colorFilter = filter; + canvas.drawImage(inputImage, Offset.zero, paint); + + recorder + .endRecording() + .toImage(size.width.round(), size.height.round()) + .then((image) { + completer.complete(image); + }); + + return completer.future; + } + + void applyFilter(ColorFilter filter, int index) { + colorFilter.value = filter; + selectedFilterIndex.value = index; + } + + Future applyFilterAndConvert(ColorFilter filter) async { + final completer = Completer(); + image.image.resolve(ImageConfiguration.empty).addListener( + ImageStreamListener((ImageInfo info, bool _) { + completer.complete(info.image); + }), + ); + final uiImage = await completer.future; + + final filteredUiImage = await createFilteredImage(uiImage, filter); + final byteData = + await filteredUiImage.toByteData(format: ui.ImageByteFormat.png); + final pngBytes = byteData!.buffer.asUint8List(); + + return Image.memory(pngBytes, fit: BoxFit.contain); + } + + return Scaffold( + appBar: AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("filter".tr()), + leading: CloseButton(color: context.primaryColor), + actions: [ + IconButton( + icon: Icon( + Icons.done_rounded, + color: context.primaryColor, + size: 24, + ), + onPressed: () async { + final filteredImage = + await applyFilterAndConvert(colorFilter.value); + context.pushRoute( + EditImageRoute( + asset: asset, + image: filteredImage, + isEdited: true, + ), + ); + }, + ), + ], + ), + backgroundColor: context.scaffoldBackgroundColor, + body: Column( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.7, + child: Center( + child: ColorFiltered( + colorFilter: colorFilter.value, + child: image, + ), + ), + ), + SizedBox( + height: 120, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: filters.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _FilterButton( + image: image, + label: filterNames[index], + filter: filters[index], + isSelected: selectedFilterIndex.value == index, + onTap: () => applyFilter(filters[index], index), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _FilterButton extends StatelessWidget { + final Image image; + final String label; + final ColorFilter filter; + final bool isSelected; + final VoidCallback onTap; + + const _FilterButton({ + required this.image, + required this.label, + required this.filter, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + GestureDetector( + onTap: onTap, + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: isSelected + ? Border.all(color: context.primaryColor, width: 3) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: ColorFiltered( + colorFilter: filter, + child: FittedBox( + fit: BoxFit.cover, + child: image, + ), + ), + ), + ), + ), + const SizedBox(height: 10), + Text(label, style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } +} diff --git a/mobile/lib/pages/library/favorite.page.dart b/mobile/lib/pages/library/favorite.page.dart index 7462dc8f21519..cc422f88c7fbf 100644 --- a/mobile/lib/pages/library/favorite.page.dart +++ b/mobile/lib/pages/library/favorite.page.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/favorite_provider.dart'; +import 'package:immich_mobile/providers/favorite.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index d226ea55a36da..3be7e9b3e5374 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -1,4 +1,5 @@ import 'dart:math'; + import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -7,27 +8,27 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:geolocator/geolocator.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/debounce.dart'; +import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/utils/map_utils.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/map/map_app_bar.dart'; import 'package:immich_mobile/widgets/map/map_asset_grid.dart'; import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart'; import 'package:immich_mobile/widgets/map/map_theme_override.dart'; import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/utils/debounce.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() @@ -304,7 +305,7 @@ class MapPage extends HookConsumerWidget { ), Positioned( right: 0, - bottom: MediaQuery.of(context).padding.bottom + 16, + bottom: MediaQuery.paddingOf(context).bottom + 16, child: ElevatedButton( onPressed: onZoomToLocation, style: ElevatedButton.styleFrom( diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart index acabc75aa4950..2ca2a379180dd 100644 --- a/mobile/lib/pages/search/search_input.page.dart +++ b/mobile/lib/pages/search/search_input.page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; @@ -19,7 +20,6 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; -import 'package:openapi/api.dart'; @RoutePage() class SearchInputPage extends HookConsumerWidget { @@ -110,7 +110,7 @@ class SearchInputPage extends HookConsumerWidget { } showPeoplePicker() { - handleOnSelect(Set value) { + handleOnSelect(Set value) { filter.value = filter.value.copyWith( people: value, ); diff --git a/mobile/lib/providers/activity_service.provider.dart b/mobile/lib/providers/activity_service.provider.dart index dcfaac883fd7c..6bd139c56504e 100644 --- a/mobile/lib/providers/activity_service.provider.dart +++ b/mobile/lib/providers/activity_service.provider.dart @@ -1,9 +1,9 @@ +import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/services/activity.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'activity_service.provider.g.dart'; @riverpod ActivityService activityService(ActivityServiceRef ref) => - ActivityService(ref.watch(apiServiceProvider)); + ActivityService(ref.watch(activityApiRepositoryProvider)); diff --git a/mobile/lib/providers/activity_service.provider.g.dart b/mobile/lib/providers/activity_service.provider.g.dart index 8e5ef43260119..d42b2a39e45f7 100644 --- a/mobile/lib/providers/activity_service.provider.g.dart +++ b/mobile/lib/providers/activity_service.provider.g.dart @@ -6,7 +6,7 @@ part of 'activity_service.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$activityServiceHash() => r'5dd4955d14f5bf01c00d7f8750d07e7ace7cc4b0'; +String _$activityServiceHash() => r'23a3ee7db71676d2719daa64217a683cc5c7eab0'; /// See also [activityService]. @ProviderFor(activityService) diff --git a/mobile/lib/providers/activity_statistics.provider.dart b/mobile/lib/providers/activity_statistics.provider.dart index afb43e8cba3d3..b1d2b4b9871f4 100644 --- a/mobile/lib/providers/activity_statistics.provider.dart +++ b/mobile/lib/providers/activity_statistics.provider.dart @@ -11,7 +11,7 @@ class ActivityStatistics extends _$ActivityStatistics { ref .watch(activityServiceProvider) .getStatistics(albumId, assetId: assetId) - .then((comments) => state = comments); + .then((stats) => state = stats.comments); return 0; } diff --git a/mobile/lib/providers/activity_statistics.provider.g.dart b/mobile/lib/providers/activity_statistics.provider.g.dart index 79856c525b77c..16a3c0e81b374 100644 --- a/mobile/lib/providers/activity_statistics.provider.g.dart +++ b/mobile/lib/providers/activity_statistics.provider.g.dart @@ -7,7 +7,7 @@ part of 'activity_statistics.provider.dart'; // ************************************************************************** String _$activityStatisticsHash() => - r'a5f7bbee1891c33b72919a34e632ca9ef9cd8dbf'; + r'1f43f0bcb11c754ca3cb586a13570db25023b9a8'; /// Copied from Dart SDK class _SystemHash { diff --git a/mobile/lib/providers/album/suggested_shared_users.provider.dart b/mobile/lib/providers/album/suggested_shared_users.provider.dart index 77518f47d0c04..fe8a1fccce861 100644 --- a/mobile/lib/providers/album/suggested_shared_users.provider.dart +++ b/mobile/lib/providers/album/suggested_shared_users.provider.dart @@ -5,5 +5,5 @@ import 'package:immich_mobile/services/user.service.dart'; final otherUsersProvider = FutureProvider.autoDispose>((ref) { UserService userService = ref.watch(userServiceProvider); - return userService.getUsersInDb(); + return userService.getUsers(); }); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 938961efb62ac..5561d3fefd683 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -86,12 +86,16 @@ class AppLifeCycleNotifier extends StateNotifier { void handleAppPause() { state = AppLifeCycleEnum.paused; _wasPaused = true; - // Do not cancel backup if manual upload is in progress - if (_ref.read(backupProvider.notifier).backupProgress != - BackUpProgressEnum.manualInProgress) { - _ref.read(backupProvider.notifier).cancelBackup(); + + if (_ref.read(authenticationProvider).isAuthenticated) { + // Do not cancel backup if manual upload is in progress + if (_ref.read(backupProvider.notifier).backupProgress != + BackUpProgressEnum.manualInProgress) { + _ref.read(backupProvider.notifier).cancelBackup(); + } + _ref.read(websocketProvider.notifier).disconnect(); } - _ref.read(websocketProvider.notifier).disconnect(); + ImmichLogger().flush(); } diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index 3c1a5ecc0119b..c7e75df79b27f 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -15,7 +16,6 @@ import 'package:immich_mobile/utils/db.dart'; import 'package:immich_mobile/utils/renderlist_generator.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:photo_manager/photo_manager.dart'; class AssetNotifier extends StateNotifier { final AssetService _assetService; @@ -257,7 +257,7 @@ class AssetNotifier extends StateNotifier { // Delete asset from device if (local.isNotEmpty) { try { - return await PhotoManager.editor.deleteWithIds(local); + return await _ref.read(assetMediaRepositoryProvider).deleteAll(local); } catch (e, stack) { log.severe("Failed to delete asset from device", e, stack); } @@ -275,28 +275,14 @@ class AssetNotifier extends StateNotifier { return isSuccess ? remote.toList() : []; } - Future toggleFavorite(List assets, [bool? status]) async { + Future toggleFavorite(List assets, [bool? status]) { status ??= !assets.every((a) => a.isFavorite); - final newAssets = await _assetService.changeFavoriteStatus(assets, status); - for (Asset? newAsset in newAssets) { - if (newAsset == null) { - log.severe("Change favorite status failed for asset"); - continue; - } - } + return _assetService.changeFavoriteStatus(assets, status); } - Future toggleArchive(List assets, [bool? status]) async { + Future toggleArchive(List assets, [bool? status]) { status ??= !assets.every((a) => a.isArchived); - final newAssets = await _assetService.changeArchiveStatus(assets, status); - int i = 0; - for (Asset oldAsset in assets) { - final newAsset = newAssets[i++]; - if (newAsset == null) { - log.severe("Change archive status failed for asset ${oldAsset.id}"); - continue; - } - } + return _assetService.changeArchiveStatus(assets, status); } } diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart new file mode 100644 index 0000000000000..d4aa2823b5b2f --- /dev/null +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -0,0 +1,191 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/download/download_state.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/share_dialog.dart'; + +class DownloadStateNotifier extends StateNotifier { + final DownloadService _downloadService; + final ShareService _shareService; + + DownloadStateNotifier( + this._downloadService, + this._shareService, + ) : super( + DownloadState( + downloadStatus: TaskStatus.complete, + showProgress: false, + taskProgress: {}, + ), + ) { + _downloadService.onImageDownloadStatus = _downloadImageCallback; + _downloadService.onVideoDownloadStatus = _downloadVideoCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; + _downloadService.onTaskProgress = _taskProgressCallback; + } + + void _updateDownloadStatus(String taskId, TaskStatus status) { + if (status == TaskStatus.canceled) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + taskId: DownloadInfo( + progress: state.taskProgress[taskId]?.progress ?? 0, + fileName: state.taskProgress[taskId]?.fileName ?? '', + status: status, + ), + }), + ); + } + + // Download live photo callback + void _downloadLivePhotoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.task.metaData.isEmpty) { + return; + } + final livePhotosId = + LivePhotosMetadata.fromJson(update.task.metaData).id; + _downloadService.saveLivePhotos(update.task, livePhotosId); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download image callback + void _downloadImageCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveImage(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download video callback + void _downloadVideoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveVideo(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + state = state.copyWith( + showProgress: true, + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + update.task.taskId: DownloadInfo( + progress: update.progress, + fileName: update.task.filename, + status: TaskStatus.running, + ), + }), + ); + } + + void _onDownloadComplete(String id) { + Future.delayed(const Duration(seconds: 2), () { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + }); + } + + void downloadAsset(Asset asset, BuildContext context) async { + await _downloadService.download(asset); + } + + void cancelDownload(String id) async { + final isCanceled = await _downloadService.cancelDownload(id); + + if (isCanceled) { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + } + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + } + + void shareAsset(Asset asset, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } +} + +final downloadStateProvider = + StateNotifierProvider( + ((ref) => DownloadStateNotifier( + ref.watch(downloadServiceProvider), + ref.watch(shareServiceProvider), + )), +); diff --git a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart b/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart deleted file mode 100644 index 631011f200bbd..0000000000000 --- a/mobile/lib/providers/asset_viewer/image_viewer_page_state.provider.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:io'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart'; -import 'package:immich_mobile/services/image_viewer.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/share_dialog.dart'; - -class ImageViewerStateNotifier extends StateNotifier { - final ImageViewerService _imageViewerService; - final ShareService _shareService; - final AlbumService _albumService; - - ImageViewerStateNotifier( - this._imageViewerService, - this._shareService, - this._albumService, - ) : super( - AssetViewerPageState( - downloadAssetStatus: DownloadAssetStatus.idle, - ), - ); - - void downloadAsset(Asset asset, BuildContext context) async { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); - - ImmichToast.show( - context: context, - msg: 'download_started'.tr(), - toastType: ToastType.info, - gravity: ToastGravity.BOTTOM, - ); - - bool isSuccess = await _imageViewerService.downloadAsset(asset); - - if (isSuccess) { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success); - - ImmichToast.show( - context: context, - msg: Platform.isAndroid - ? 'download_sucess_android'.tr() - : 'download_sucess'.tr(), - toastType: ToastType.success, - gravity: ToastGravity.BOTTOM, - ); - _albumService.refreshDeviceAlbums(); - } else { - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error); - ImmichToast.show( - context: context, - msg: 'download_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - - state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); - } - - void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } -} - -final imageViewerStateProvider = - StateNotifierProvider( - ((ref) => ImageViewerStateNotifier( - ref.watch(imageViewerServiceProvider), - ref.watch(shareServiceProvider), - ref.watch(albumServiceProvider), - )), -); diff --git a/mobile/lib/providers/backup/backup.provider.dart b/mobile/lib/providers/backup/backup.provider.dart index 02f1f07904f97..dc6d2f7cc89cb 100644 --- a/mobile/lib/providers/backup/backup.provider.dart +++ b/mobile/lib/providers/backup/backup.provider.dart @@ -5,6 +5,10 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/available_album.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; @@ -13,6 +17,9 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; @@ -28,7 +35,7 @@ import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; class BackupNotifier extends StateNotifier { BackupNotifier( @@ -38,6 +45,9 @@ class BackupNotifier extends StateNotifier { this._backgroundService, this._galleryPermissionNotifier, this._db, + this._albumMediaRepository, + this._fileMediaRepository, + this._backupRepository, this.ref, ) : super( BackUpState( @@ -86,6 +96,9 @@ class BackupNotifier extends StateNotifier { final BackgroundService _backgroundService; final GalleryPermissionNotifier _galleryPermissionNotifier; final Isar _db; + final IAlbumMediaRepository _albumMediaRepository; + final IFileMediaRepository _fileMediaRepository; + final IBackupRepository _backupRepository; final Ref ref; /// @@ -224,38 +237,44 @@ class BackupNotifier extends StateNotifier { Stopwatch stopwatch = Stopwatch()..start(); // Get all albums on the device List availableAlbums = []; - List albums = await PhotoManager.getAssetPathList( - hasAll: true, - type: RequestType.common, - ); + List albums = await _albumMediaRepository.getAll(); // Map of id -> album for quick album lookup later on. - Map albumMap = {}; + Map albumMap = {}; log.info('Found ${albums.length} local albums'); - for (AssetPathEntity album in albums) { - AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); + for (Album album in albums) { + AvailableAlbum availableAlbum = AvailableAlbum( + album: album, + assetCount: await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(album.localId!), + ); availableAlbums.add(availableAlbum); - albumMap[album.id] = album; + albumMap[album.localId!] = album; } state = state.copyWith(availableAlbums: availableAlbums); final List excludedBackupAlbums = - await _backupService.excludedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); final List selectedBackupAlbums = - await _backupService.selectedAlbumsQuery().findAll(); + await _backupRepository.getAllBySelection(BackupSelection.select); - // Generate AssetPathEntity from id to add to local state final Set selectedAlbums = {}; for (final BackupAlbum ba in selectedBackupAlbums) { final albumAsset = albumMap[ba.id]; if (albumAsset != null) { selectedAlbums.add( - AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), + AvailableAlbum( + album: albumAsset, + assetCount: + await _albumMediaRepository.getAssetCount(albumAsset.localId!), + lastBackup: ba.lastBackup, + ), ); } else { log.severe('Selected album not found'); @@ -268,7 +287,13 @@ class BackupNotifier extends StateNotifier { if (albumAsset != null) { excludedAlbums.add( - AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup), + AvailableAlbum( + album: albumAsset, + assetCount: await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(albumAsset.localId!), + lastBackup: ba.lastBackup, + ), ); } else { log.severe('Excluded album not found'); @@ -292,28 +317,32 @@ class BackupNotifier extends StateNotifier { /// Those assets are unique and are used as the total assets /// Future _updateBackupAssetCount() async { + // Save to persistent storage + await _updatePersistentAlbumsSelection(); + final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds(); final Set assetsFromSelectedAlbums = {}; final Set assetsFromExcludedAlbums = {}; for (final album in state.selectedBackupAlbums) { - final assetCount = await album.albumEntity.assetCountAsync; + final assetCount = await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(album.album.localId!); if (assetCount == 0) { continue; } - final assets = await album.albumEntity.getAssetListRange( - start: 0, - end: assetCount, - ); + final assets = await ref + .read(albumMediaRepositoryProvider) + .getAssets(album.album.localId!); // Add album's name to the asset info for (final asset in assets) { List albumNames = [album.name]; final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull( - (a) => a.asset.id == asset.id, + (a) => a.asset.localId == asset.localId, ); if (existingAsset != null) { @@ -331,16 +360,17 @@ class BackupNotifier extends StateNotifier { } for (final album in state.excludedBackupAlbums) { - final assetCount = await album.albumEntity.assetCountAsync; + final assetCount = await ref + .read(albumMediaRepositoryProvider) + .getAssetCount(album.album.localId!); if (assetCount == 0) { continue; } - final assets = await album.albumEntity.getAssetListRange( - start: 0, - end: assetCount, - ); + final assets = await ref + .read(albumMediaRepositoryProvider) + .getAssets(album.album.localId!); for (final asset in assets) { assetsFromExcludedAlbums.add( @@ -360,14 +390,14 @@ class BackupNotifier extends StateNotifier { // Find asset that were backup from selected albums final Set selectedAlbumsBackupAssets = - Set.from(allUniqueAssets.map((e) => e.asset.id)); + Set.from(allUniqueAssets.map((e) => e.asset.localId)); selectedAlbumsBackupAssets .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId)); // Remove duplicated asset from all unique assets allUniqueAssets.removeWhere( - (candidate) => duplicatedAssetIds.contains(candidate.asset.id), + (candidate) => duplicatedAssetIds.contains(candidate.asset.localId), ); if (allUniqueAssets.isEmpty) { @@ -385,9 +415,6 @@ class BackupNotifier extends StateNotifier { selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, ); } - - // Save to persistent storage - await _updatePersistentAlbumsSelection(); } /// Get all necessary information for calculating the available albums, @@ -454,7 +481,7 @@ class BackupNotifier extends StateNotifier { final hasPermission = _galleryPermissionNotifier.hasPermission; if (hasPermission) { - await PhotoManager.clearFileCache(); + await _fileMediaRepository.clearFileCache(); if (state.allUniqueAssets.isEmpty) { log.info("No Asset On Device - Abort Backup Process"); @@ -465,7 +492,7 @@ class BackupNotifier extends StateNotifier { Set assetsWillBeBackup = Set.from(state.allUniqueAssets); // Remove item that has already been backed up for (final assetId in state.allAssetsInDatabase) { - assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId); + assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId); } if (assetsWillBeBackup.isEmpty) { @@ -531,7 +558,8 @@ class BackupNotifier extends StateNotifier { state = state.copyWith( allUniqueAssets: state.allUniqueAssets .where( - (candidate) => candidate.asset.id != result.candidate.asset.id, + (candidate) => + candidate.asset.localId != result.candidate.asset.localId, ) .toSet(), ); @@ -539,11 +567,11 @@ class BackupNotifier extends StateNotifier { state = state.copyWith( selectedAlbumsBackupAssetsIds: { ...state.selectedAlbumsBackupAssetsIds, - result.candidate.asset.id, + result.candidate.asset.localId!, }, allAssetsInDatabase: [ ...state.allAssetsInDatabase, - result.candidate.asset.id, + result.candidate.asset.localId!, ], ); } @@ -552,7 +580,7 @@ class BackupNotifier extends StateNotifier { state.selectedAlbumsBackupAssetsIds.length == 0) { final latestAssetBackup = state.allUniqueAssets - .map((candidate) => candidate.asset.modifiedDateTime) + .map((candidate) => candidate.asset.fileModifiedAt) .reduce( (v, e) => e.isAfter(v) ? e : v, ); @@ -741,6 +769,9 @@ final backupProvider = ref.watch(backgroundServiceProvider), ref.watch(galleryPermissionNotifier.notifier), ref.watch(dbProvider), + ref.watch(albumMediaRepositoryProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(backupRepositoryProvider), ref, ); }); diff --git a/mobile/lib/providers/backup/backup_verification.provider.dart b/mobile/lib/providers/backup/backup_verification.provider.dart index 894b807ec8759..7b8e7b8c4b6d0 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.dart @@ -35,7 +35,7 @@ class BackupVerification extends _$BackupVerification { return; } final connection = await Connectivity().checkConnectivity(); - if (connection != ConnectivityResult.wifi) { + if (connection.contains(ConnectivityResult.wifi)) { if (context.mounted) { ImmichToast.show( context: context, diff --git a/mobile/lib/providers/backup/manual_upload.provider.dart b/mobile/lib/providers/backup/manual_upload.provider.dart index a76b56fea7f8a..192126f0859c7 100644 --- a/mobile/lib/providers/backup/manual_upload.provider.dart +++ b/mobile/lib/providers/backup/manual_upload.provider.dart @@ -6,8 +6,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/widgets.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -24,10 +27,9 @@ import 'package:immich_mobile/providers/app_life_cycle.provider.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final manualUploadProvider = StateNotifierProvider((ref) { @@ -35,6 +37,7 @@ final manualUploadProvider = ref.watch(localNotificationService), ref.watch(backupProvider.notifier), ref.watch(backupServiceProvider), + ref.watch(backupRepositoryProvider), ref, ); }); @@ -44,12 +47,14 @@ class ManualUploadNotifier extends StateNotifier { final LocalNotificationService _localNotificationService; final BackupNotifier _backupProvider; final BackupService _backupService; + final BackupRepository _backupRepository; final Ref ref; ManualUploadNotifier( this._localNotificationService, this._backupProvider, this._backupService, + this._backupRepository, this.ref, ) : super( ManualUploadState( @@ -193,17 +198,10 @@ class ManualUploadNotifier extends StateNotifier { _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress); if (ref.read(galleryPermissionNotifier.notifier).hasPermission) { - await PhotoManager.clearFileCache(); - - // We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases - // where platform specific fields such as `subtype` used to detect platform specific assets such as - // LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local - List allAssetsFromDevice = await Future.wait( - allManualUploads - // Filter local only assets - .where((e) => e.isLocal && !e.isRemote) - .map((e) => e.local!.obtainForNewProperties()), - ); + await ref.read(fileMediaRepositoryProvider).clearFileCache(); + + final allAssetsFromDevice = + allManualUploads.where((e) => e.isLocal && !e.isRemote).toList(); if (allAssetsFromDevice.length != allManualUploads.length) { _log.warning( @@ -212,20 +210,26 @@ class ManualUploadNotifier extends StateNotifier { } final selectedBackupAlbums = - _backupService.selectedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.select); final excludedBackupAlbums = - _backupService.excludedAlbumsQuery().findAllSync(); + await _backupRepository.getAllBySelection(BackupSelection.exclude); // Get candidates from selected albums and excluded albums Set candidates = await _backupService.buildUploadCandidates( selectedBackupAlbums, excludedBackupAlbums, + useTimeFilter: false, ); - // Extrack candidate from allAssetsFromDevice.nonNulls - final uploadAssets = candidates - .where((e) => allAssetsFromDevice.nonNulls.contains(e.asset)); + // Extrack candidate from allAssetsFromDevice + final uploadAssets = candidates.where( + (candidate) => + allAssetsFromDevice.firstWhereOrNull( + (asset) => asset.localId == candidate.asset.localId, + ) != + null, + ); if (uploadAssets.isEmpty) { debugPrint("[_startUpload] No Assets to upload - Abort Process"); diff --git a/mobile/lib/providers/favorite_provider.dart b/mobile/lib/providers/favorite.provider.dart similarity index 100% rename from mobile/lib/providers/favorite_provider.dart rename to mobile/lib/providers/favorite.provider.dart diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index dc1b8a98456aa..c1bafa6c5a0ba 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -9,7 +9,7 @@ import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset class ImmichLocalImageProvider extends ImageProvider { diff --git a/mobile/lib/providers/image/immich_local_thumbnail_provider.dart b/mobile/lib/providers/image/immich_local_thumbnail_provider.dart index 28e78ae762197..69cdb105c0f72 100644 --- a/mobile/lib/providers/image/immich_local_thumbnail_provider.dart +++ b/mobile/lib/providers/image/immich_local_thumbnail_provider.dart @@ -6,7 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset /// Only viable diff --git a/mobile/lib/providers/map/map_state.provider.dart b/mobile/lib/providers/map/map_state.provider.dart index 6d1630bba2e18..189a23cd0aad1 100644 --- a/mobile/lib/providers/map/map_state.provider.dart +++ b/mobile/lib/providers/map/map_state.provider.dart @@ -1,28 +1,23 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; import 'package:immich_mobile/models/map/map_state.model.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'map_state.provider.g.dart'; @Riverpod(keepAlive: true) class MapStateNotifier extends _$MapStateNotifier { - final _log = Logger("MapStateNotifier"); - @override MapState build() { final appSettingsProvider = ref.read(appSettingsServiceProvider); - // Fetch and save the Style JSONs - loadStyles(); + final lightStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapLightStyleUrl; + final darkStyleUrl = + ref.read(serverInfoProvider).serverConfig.mapDarkStyleUrl; + return MapState( themeMode: ThemeMode.values[ appSettingsProvider.getSetting(AppSettingsEnum.mapThemeMode)], @@ -34,65 +29,11 @@ class MapStateNotifier extends _$MapStateNotifier { appSettingsProvider.getSetting(AppSettingsEnum.mapwithPartners), relativeTime: appSettingsProvider.getSetting(AppSettingsEnum.mapRelativeDate), + lightStyleFetched: AsyncData(lightStyleUrl), + darkStyleFetched: AsyncData(darkStyleUrl), ); } - void loadStyles() async { - final documents = (await getApplicationDocumentsDirectory()).path; - - // Set to loading - state = state.copyWith(lightStyleFetched: const AsyncLoading()); - - // Fetch and save light theme - final lightResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.light); - - if (lightResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current), - ); - _log.severe( - "Cannot fetch map light style", - lightResponse.toLoggerString(), - ); - return; - } - - final lightJSON = lightResponse.body; - final lightFile = await File("$documents/map-style-light.json") - .writeAsString(lightJSON, flush: true); - - // Update state with path - state = - state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path)); - - // Set to loading - state = state.copyWith(darkStyleFetched: const AsyncLoading()); - - // Fetch and save dark theme - final darkResponse = await ref - .read(apiServiceProvider) - .mapApi - .getMapStyleWithHttpInfo(MapTheme.dark); - - if (darkResponse.statusCode >= HttpStatus.badRequest) { - state = state.copyWith( - darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current), - ); - _log.severe("Cannot fetch map dark style", darkResponse.toLoggerString()); - return; - } - - final darkJSON = darkResponse.body; - final darkFile = await File("$documents/map-style-dark.json") - .writeAsString(darkJSON, flush: true); - - // Update state with path - state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path)); - } - void switchTheme(ThemeMode mode) { ref.read(appSettingsServiceProvider).setSetting( AppSettingsEnum.mapThemeMode, diff --git a/mobile/lib/providers/map/map_state.provider.g.dart b/mobile/lib/providers/map/map_state.provider.g.dart index eff7b4b68e60f..23a570d1c8789 100644 --- a/mobile/lib/providers/map/map_state.provider.g.dart +++ b/mobile/lib/providers/map/map_state.provider.g.dart @@ -6,7 +6,7 @@ part of 'map_state.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$mapStateNotifierHash() => r'31fafe17aa85c48379a22ed3db3cc94af59ce5b8'; +String _$mapStateNotifierHash() => r'22e4e571bd0730dbc34b109255a62b920e9c7d66'; /// See also [MapStateNotifier]. @ProviderFor(MapStateNotifier) diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index e2c243354b536..7c956f0a37b52 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -1,14 +1,14 @@ +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/services/person.service.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'people.provider.g.dart'; @riverpod -Future> getAllPeople( +Future> getAllPeople( GetAllPeopleRef ref, ) async { final PersonService personService = ref.read(personServiceProvider); diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index db2edfb9567aa..c5ff6287cd7a8 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -6,12 +6,11 @@ part of 'people.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$getAllPeopleHash() => r'4eff6666be5a74710d1e8587e01d8154310d85bd'; +String _$getAllPeopleHash() => r'3417b7e0c211382d4480a415e352139995d57b6d'; /// See also [getAllPeople]. @ProviderFor(getAllPeople) -final getAllPeopleProvider = - AutoDisposeFutureProvider>.internal( +final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( getAllPeople, name: r'getAllPeopleProvider', debugGetCreateSourceHash: @@ -20,7 +19,7 @@ final getAllPeopleProvider = allTransitiveDependencies: null, ); -typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; +typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832'; /// Copied from Dart SDK diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 6327f992f5cd0..14521b06f64ca 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -34,6 +34,9 @@ class ServerInfoNotifier extends StateNotifier { trashDays: 30, oauthButtonText: '', externalDomain: '', + mapLightStyleUrl: + 'https://tiles.immich.cloud/v1/style/light.json', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', ), serverDiskInfo: const ServerDiskInfo( diskAvailable: "0", diff --git a/mobile/lib/repositories/activity_api.repository.dart b/mobile/lib/repositories/activity_api.repository.dart new file mode 100644 index 0000000000000..8da3759709327 --- /dev/null +++ b/mobile/lib/repositories/activity_api.repository.dart @@ -0,0 +1,67 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final activityApiRepositoryProvider = Provider( + (ref) => ActivityApiRepository(ref.watch(apiServiceProvider).activitiesApi), +); + +class ActivityApiRepository extends ApiRepository + implements IActivityApiRepository { + final ActivitiesApi _api; + + ActivityApiRepository(this._api); + + @override + Future> getAll(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivities(albumId, assetId: assetId)); + return response.map(_toActivity).toList(); + } + + @override + Future create( + String albumId, + ActivityType type, { + String? assetId, + String? comment, + }) async { + final dto = ActivityCreateDto( + albumId: albumId, + type: type == ActivityType.comment + ? ReactionType.comment + : ReactionType.like, + assetId: assetId, + comment: comment, + ); + final response = await checkNull(_api.createActivity(dto)); + return _toActivity(response); + } + + @override + Future delete(String id) { + return checkNull(_api.deleteActivity(id)); + } + + @override + Future getStats(String albumId, {String? assetId}) async { + final response = + await checkNull(_api.getActivityStatistics(albumId, assetId: assetId)); + return ActivityStats(comments: response.comments); + } + + static Activity _toActivity(ActivityResponseDto dto) => Activity( + id: dto.id, + createdAt: dto.createdAt, + type: dto.type == ReactionType.comment + ? ActivityType.comment + : ActivityType.like, + user: User.fromSimpleUserDto(dto.user), + assetId: dto.assetId, + comment: dto.comment, + ); +} diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart new file mode 100644 index 0000000000000..35f5cae32722c --- /dev/null +++ b/mobile/lib/repositories/album.repository.dart @@ -0,0 +1,121 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final albumRepositoryProvider = + Provider((ref) => AlbumRepository(ref.watch(dbProvider))); + +class AlbumRepository extends DatabaseRepository implements IAlbumRepository { + AlbumRepository(super.db); + + @override + Future count({bool? local}) { + final baseQuery = db.albums.where(); + final QueryBuilder query; + switch (local) { + case null: + query = baseQuery.noOp(); + case true: + query = baseQuery.localIdIsNotNull(); + case false: + query = baseQuery.remoteIdIsNotNull(); + } + return query.count(); + } + + @override + Future create(Album album) => txn(() => db.albums.store(album)); + + @override + Future getByName(String name, {bool? shared, bool? remote}) { + var query = db.albums.filter().nameEqualTo(name); + if (shared != null) { + query = query.sharedEqualTo(shared); + } + if (remote == true) { + query = query.localIdIsNull(); + } else if (remote == false) { + query = query.remoteIdIsNull(); + } + return query.findFirst(); + } + + @override + Future update(Album album) => txn(() => db.albums.store(album)); + + @override + Future delete(int albumId) => txn(() => db.albums.delete(albumId)); + + @override + Future> getAll({ + bool? shared, + bool? remote, + int? ownerId, + AlbumSort? sortBy, + }) { + final baseQuery = db.albums.where(); + final QueryBuilder afterWhere; + if (remote == null) { + afterWhere = baseQuery.noOp(); + } else if (remote) { + afterWhere = baseQuery.remoteIdIsNotNull(); + } else { + afterWhere = baseQuery.localIdIsNotNull(); + } + QueryBuilder filterQuery = + afterWhere.filter().noOp(); + if (shared != null) { + filterQuery = filterQuery.sharedEqualTo(true); + } + if (ownerId != null) { + filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId)); + } + final QueryBuilder query; + switch (sortBy) { + case null: + query = filterQuery.noOp(); + case AlbumSort.remoteId: + query = filterQuery.sortByRemoteId(); + case AlbumSort.localId: + query = filterQuery.sortByLocalId(); + } + return query.findAll(); + } + + @override + Future get(int id) => db.albums.get(id); + + @override + Future removeUsers(Album album, List users) => + txn(() => album.sharedUsers.update(unlink: users)); + + @override + Future addAssets(Album album, List assets) => + txn(() => album.assets.update(link: assets)); + + @override + Future removeAssets(Album album, List assets) => + txn(() => album.assets.update(unlink: assets)); + + @override + Future recalculateMetadata(Album album) async { + album.startDate = await album.assets.filter().fileCreatedAtProperty().min(); + album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); + album.lastModifiedAssetTimestamp = + await album.assets.filter().updatedAtProperty().max(); + return album; + } + + @override + Future addUsers(Album album, List users) => + txn(() => album.sharedUsers.update(link: users)); + + @override + Future deleteAllLocal() => + txn(() => db.albums.where().localIdIsNotNull().deleteAll()); +} diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart new file mode 100644 index 0000000000000..5d0b56dc7882a --- /dev/null +++ b/mobile/lib/repositories/album_api.repository.dart @@ -0,0 +1,166 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/album_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final albumApiRepositoryProvider = Provider( + (ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi), +); + +class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { + final AlbumsApi _api; + + AlbumApiRepository(this._api); + + @override + Future get(String id) async { + final dto = await checkNull(_api.getAlbumInfo(id)); + return _toAlbum(dto); + } + + @override + Future> getAll({bool? shared}) async { + final dtos = await checkNull(_api.getAllAlbums(shared: shared)); + return dtos.map(_toAlbum).toList(); + } + + @override + Future create( + String name, { + required Iterable assetIds, + Iterable sharedUserIds = const [], + }) async { + final users = sharedUserIds.map( + (id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor), + ); + final responseDto = await checkNull( + _api.createAlbum( + CreateAlbumDto( + albumName: name, + assetIds: assetIds.toList(), + albumUsers: users.toList(), + ), + ), + ); + return _toAlbum(responseDto); + } + + @override + Future update( + String albumId, { + String? name, + String? thumbnailAssetId, + String? description, + bool? activityEnabled, + }) async { + final response = await checkNull( + _api.updateAlbumInfo( + albumId, + UpdateAlbumDto( + albumName: name, + albumThumbnailAssetId: thumbnailAssetId, + description: description, + isActivityEnabled: activityEnabled, + ), + ), + ); + return _toAlbum(response); + } + + @override + Future delete(String albumId) { + return _api.deleteAlbum(albumId); + } + + @override + Future<({List added, List duplicates})> addAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await checkNull( + _api.addAssetsToAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + + final List added = []; + final List duplicates = []; + + for (final result in response) { + if (result.success) { + added.add(result.id); + } else if (result.error == BulkIdResponseDtoErrorEnum.duplicate) { + duplicates.add(result.id); + } + } + return (added: added, duplicates: duplicates); + } + + @override + Future<({List removed, List failed})> removeAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await checkNull( + _api.removeAssetFromAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + final List removed = [], failed = []; + for (final dto in response) { + if (dto.success) { + removed.add(dto.id); + } else { + failed.add(dto.id); + } + } + return (removed: removed, failed: failed); + } + + @override + Future addUsers(String albumId, Iterable userIds) async { + final albumUsers = + userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); + final response = await checkNull( + _api.addUsersToAlbum( + albumId, + AddUsersDto(albumUsers: albumUsers), + ), + ); + return _toAlbum(response); + } + + @override + Future removeUser(String albumId, {required String userId}) { + return _api.removeUserFromAlbum(albumId, userId); + } + + static Album _toAlbum(AlbumResponseDto dto) { + final Album album = Album( + remoteId: dto.id, + name: dto.albumName, + createdAt: dto.createdAt, + modifiedAt: dto.updatedAt, + lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp, + shared: dto.shared, + startDate: dto.startDate, + endDate: dto.endDate, + activityEnabled: dto.isActivityEnabled, + ); + album.remoteAssetCount = dto.assetCount; + album.owner.value = User.fromSimpleUserDto(dto.owner); + album.remoteThumbnailAssetId = dto.albumThumbnailAssetId; + final users = dto.albumUsers + .map((albumUser) => User.fromSimpleUserDto(albumUser.user)); + album.sharedUsers.addAll(users); + final assets = dto.assets.map(Asset.remote).toList(); + album.assets.addAll(assets); + return album; + } +} diff --git a/mobile/lib/repositories/album_media.repository.dart b/mobile/lib/repositories/album_media.repository.dart new file mode 100644 index 0000000000000..c3795f75df4e1 --- /dev/null +++ b/mobile/lib/repositories/album_media.repository.dart @@ -0,0 +1,93 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:photo_manager/photo_manager.dart' hide AssetType; + +final albumMediaRepositoryProvider = Provider((ref) => AlbumMediaRepository()); + +class AlbumMediaRepository implements IAlbumMediaRepository { + @override + Future> getAll() async { + final List assetPathEntities = + await PhotoManager.getAssetPathList( + hasAll: true, + filterOption: FilterOptionGroup(containsPathModified: true), + ); + return assetPathEntities.map(_toAlbum).toList(); + } + + @override + Future> getAssetIds(String albumId) async { + final album = await AssetPathEntity.fromId(albumId); + final List assets = + await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff); + return assets.map((e) => e.id).toList(); + } + + @override + Future getAssetCount(String albumId) async { + final album = await AssetPathEntity.fromId(albumId); + return album.assetCountAsync; + } + + @override + Future> getAssets( + String albumId, { + int start = 0, + int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, + bool orderByModificationDate = false, + }) async { + final onDevice = await AssetPathEntity.fromId( + albumId, + filterOption: FilterOptionGroup( + containsPathModified: true, + orders: orderByModificationDate + ? [const OrderOption(type: OrderOptionType.updateDate)] + : [], + imageOption: const FilterOption(needTitle: true), + videoOption: const FilterOption(needTitle: true), + updateTimeCond: modifiedFrom == null && modifiedUntil == null + ? null + : DateTimeCond( + min: modifiedFrom ?? DateTime.utc(-271820), + max: modifiedUntil ?? DateTime.utc(275760), + ), + ), + ); + + final List assets = + await onDevice.getAssetListRange(start: start, end: end); + return assets.map(AssetMediaRepository.toAsset).toList().cast(); + } + + @override + Future get( + String id, { + DateTime? modifiedFrom, + DateTime? modifiedUntil, + }) async { + final assetPathEntity = await AssetPathEntity.fromId(id); + return _toAlbum(assetPathEntity); + } + + static Album _toAlbum(AssetPathEntity assetPathEntity) { + final Album album = Album( + name: assetPathEntity.name, + createdAt: + assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(), + modifiedAt: + assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(), + shared: false, + activityEnabled: false, + ); + album.owner.value = Store.get(StoreKey.currentUser); + album.localId = assetPathEntity.id; + album.isAll = assetPathEntity.isAll; + return album; + } +} diff --git a/mobile/lib/repositories/api.repository.dart b/mobile/lib/repositories/api.repository.dart new file mode 100644 index 0000000000000..b454c77f9b7da --- /dev/null +++ b/mobile/lib/repositories/api.repository.dart @@ -0,0 +1,9 @@ +import 'package:immich_mobile/constants/errors.dart'; + +abstract class ApiRepository { + Future checkNull(Future future) async { + final response = await future; + if (response == null) throw NoResponseDtoError(); + return response; + } +} diff --git a/mobile/lib/repositories/asset.repository.dart b/mobile/lib/repositories/asset.repository.dart new file mode 100644 index 0000000000000..eaaafd3045a39 --- /dev/null +++ b/mobile/lib/repositories/asset.repository.dart @@ -0,0 +1,248 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/android_device_asset.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/device_asset.entity.dart'; +import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final assetRepositoryProvider = + Provider((ref) => AssetRepository(ref.watch(dbProvider))); + +class AssetRepository extends DatabaseRepository implements IAssetRepository { + AssetRepository(super.db); + + @override + Future> getByAlbum( + Album album, { + Iterable notOwnedBy = const [], + int? ownerId, + AssetState? state, + AssetSort? sortBy, + }) { + var query = album.assets.filter(); + if (notOwnedBy.length == 1) { + query = query.not().ownerIdEqualTo(notOwnedBy.first); + } else if (notOwnedBy.isNotEmpty) { + query = + query.not().anyOf(notOwnedBy, (q, int id) => q.ownerIdEqualTo(id)); + } + if (ownerId != null) { + query = query.ownerIdEqualTo(ownerId); + } + + switch (state) { + case null: + break; + case AssetState.local: + query = query.remoteIdIsNull(); + case AssetState.remote: + query = query.localIdIsNull(); + case AssetState.merged: + query = query.localIdIsNotNull().remoteIdIsNotNull(); + } + + final QueryBuilder sortedQuery; + + switch (sortBy) { + case null: + sortedQuery = query.noOp(); + case AssetSort.checksum: + sortedQuery = query.sortByChecksum(); + case AssetSort.ownerIdChecksum: + sortedQuery = query.sortByOwnerId().thenByChecksum(); + } + + return sortedQuery.findAll(); + } + + @override + Future deleteById(List ids) => txn(() async { + await db.assets.deleteAll(ids); + await db.exifInfos.deleteAll(ids); + }); + + @override + Future getByRemoteId(String id) => db.assets.getByRemoteId(id); + + @override + Future> getAllByRemoteId( + Iterable ids, { + AssetState? state, + }) => + _getAllByRemoteIdImpl(ids, state).findAll(); + + QueryBuilder _getAllByRemoteIdImpl( + Iterable ids, + AssetState? state, + ) { + final query = db.assets.remote(ids).filter(); + switch (state) { + case null: + return query.noOp(); + case AssetState.local: + return query.remoteIdIsNull(); + case AssetState.remote: + return query.localIdIsNull(); + case AssetState.merged: + return query.localIdIsNotEmpty().remoteIdIsNotNull(); + } + } + + @override + Future> getAll({ + required int ownerId, + AssetState? state, + AssetSort? sortBy, + int? limit, + }) { + final baseQuery = db.assets.where(); + final QueryBuilder filteredQuery; + switch (state) { + case null: + filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(); + case AssetState.local: + filteredQuery = baseQuery + .remoteIdIsNull() + .filter() + .localIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.remote: + filteredQuery = baseQuery + .localIdIsNull() + .filter() + .remoteIdIsNotNull() + .ownerIdEqualTo(ownerId); + case AssetState.merged: + filteredQuery = baseQuery + .ownerIdEqualToAnyChecksum(ownerId) + .filter() + .remoteIdIsNotNull() + .localIdIsNotNull(); + } + + final QueryBuilder query; + switch (sortBy) { + case null: + query = filteredQuery.noOp(); + case AssetSort.checksum: + query = filteredQuery.sortByChecksum(); + case AssetSort.ownerIdChecksum: + query = filteredQuery.sortByOwnerId().thenByChecksum(); + } + + return limit == null ? query.findAll() : query.limit(limit).findAll(); + } + + @override + Future> updateAll(List assets) async { + await txn(() => db.assets.putAll(assets)); + return assets; + } + + @override + Future> getMatches({ + required List assets, + required int ownerId, + AssetState? state, + int limit = 100, + }) { + final baseQuery = db.assets.where(); + final QueryBuilder query; + switch (state) { + case null: + query = baseQuery.noOp(); + case AssetState.local: + query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull(); + case AssetState.remote: + query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull(); + case AssetState.merged: + query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(); + } + return _getMatchesImpl(query, ownerId, assets, limit); + } + + @override + Future> getDeviceAssetsById(List ids) => + Platform.isAndroid + ? db.androidDeviceAssets.getAll(ids.cast()) + : db.iOSDeviceAssets.getAllById(ids.cast()); + + @override + Future upsertDeviceAssets(List deviceAssets) => txn( + () => Platform.isAndroid + ? db.androidDeviceAssets.putAll(deviceAssets.cast()) + : db.iOSDeviceAssets.putAll(deviceAssets.cast()), + ); + + @override + Future update(Asset asset) async { + await txn(() => asset.put(db)); + return asset; + } + + @override + Future upsertDuplicatedAssets(Iterable duplicatedAssets) => txn( + () => db.duplicatedAssets + .putAll(duplicatedAssets.map(DuplicatedAsset.new).toList()), + ); + + @override + Future> getAllDuplicatedAssetIds() => + db.duplicatedAssets.where().idProperty().findAll(); + + @override + Future getByOwnerIdChecksum(int ownerId, String checksum) => + db.assets.getByOwnerIdChecksum(ownerId, checksum); + + @override + Future> getAllByOwnerIdChecksum( + List ids, + List checksums, + ) => + db.assets.getAllByOwnerIdChecksum(ids, checksums); + + @override + Future> getAllLocal() => + db.assets.where().localIdIsNotNull().findAll(); + + @override + Future deleteAllByRemoteId(List ids, {AssetState? state}) => + txn(() => _getAllByRemoteIdImpl(ids, state).deleteAll()); +} + +Future> _getMatchesImpl( + QueryBuilder query, + int ownerId, + List assets, + int limit, +) => + query + .ownerIdEqualTo(ownerId) + .anyOf( + assets, + (q, Asset a) => q + .fileNameEqualTo(a.fileName) + .and() + .durationInSecondsEqualTo(a.durationInSeconds) + .and() + .fileCreatedAtBetween( + a.fileCreatedAt.subtract(const Duration(hours: 12)), + a.fileCreatedAt.add(const Duration(hours: 12)), + ) + .and() + .not() + .checksumEqualTo(a.checksum), + ) + .sortByFileName() + .thenByFileCreatedAt() + .thenByFileModifiedAt() + .limit(limit) + .findAll(); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart new file mode 100644 index 0000000000000..54d57c4dfccf6 --- /dev/null +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -0,0 +1,51 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final assetApiRepositoryProvider = Provider( + (ref) => AssetApiRepository( + ref.watch(apiServiceProvider).assetsApi, + ref.watch(apiServiceProvider).searchApi, + ), +); + +class AssetApiRepository extends ApiRepository implements IAssetApiRepository { + final AssetsApi _api; + final SearchApi _searchApi; + + AssetApiRepository(this._api, this._searchApi); + + @override + Future update(String id, {String? description}) async { + final response = await checkNull( + _api.updateAsset(id, UpdateAssetDto(description: description)), + ); + return Asset.remote(response); + } + + @override + Future> search({List personIds = const []}) async { + // TODO this always fetches all assets, change API and usage to actually do pagination + final List result = []; + bool hasNext = true; + int currentPage = 1; + while (hasNext) { + final response = await checkNull( + _searchApi.searchMetadata( + MetadataSearchDto( + personIds: personIds, + page: currentPage, + size: 1000, + ), + ), + ); + result.addAll(response.assets.items.map(Asset.remote)); + hasNext = response.assets.nextPage != null; + currentPage++; + } + return result; + } +} diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart new file mode 100644 index 0000000000000..68fffa08a6fcb --- /dev/null +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -0,0 +1,59 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:photo_manager/photo_manager.dart' hide AssetType; + +final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository()); + +class AssetMediaRepository implements IAssetMediaRepository { + @override + Future> deleteAll(List ids) => + PhotoManager.editor.deleteWithIds(ids); + + @override + Future get(String id) async { + final entity = await AssetEntity.fromId(id); + return toAsset(entity); + } + + static Asset? toAsset(AssetEntity? local) { + if (local == null) return null; + final Asset asset = Asset( + checksum: "", + localId: local.id, + ownerId: Store.get(StoreKey.currentUser).isarId, + fileCreatedAt: local.createDateTime, + fileModifiedAt: local.modifiedDateTime, + updatedAt: local.modifiedDateTime, + durationInSeconds: local.duration, + type: AssetType.values[local.typeInt], + fileName: local.title!, + width: local.width, + height: local.height, + isFavorite: local.isFavorite, + ); + if (asset.fileCreatedAt.year == 1970) { + asset.fileCreatedAt = asset.fileModifiedAt; + } + if (local.latitude != null) { + asset.exifInfo = ExifInfo(lat: local.latitude, long: local.longitude); + } + asset.local = local; + return asset; + } + + @override + Future getOriginalFilename(String id) async { + final entity = await AssetEntity.fromId(id); + + if (entity == null) { + return null; + } + + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + return await entity.titleAsync; + } +} diff --git a/mobile/lib/repositories/backup.repository.dart b/mobile/lib/repositories/backup.repository.dart new file mode 100644 index 0000000000000..61997ff23ae22 --- /dev/null +++ b/mobile/lib/repositories/backup.repository.dart @@ -0,0 +1,42 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final backupRepositoryProvider = + Provider((ref) => BackupRepository(ref.watch(dbProvider))); + +class BackupRepository extends DatabaseRepository implements IBackupRepository { + BackupRepository(super.db); + + @override + Future> getAll({BackupAlbumSort? sort}) { + final baseQuery = db.backupAlbums.where(); + final QueryBuilder query; + switch (sort) { + case null: + query = baseQuery.noOp(); + case BackupAlbumSort.id: + query = baseQuery.sortById(); + } + return query.findAll(); + } + + @override + Future> getIdsBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll(); + + @override + Future> getAllBySelection(BackupSelection backup) => + db.backupAlbums.filter().selectionEqualTo(backup).findAll(); + + @override + Future deleteAll(List ids) => + txn(() => db.backupAlbums.deleteAll(ids)); + + @override + Future updateAll(List backupAlbums) => + txn(() => db.backupAlbums.putAll(backupAlbums)); +} diff --git a/mobile/lib/repositories/database.repository.dart b/mobile/lib/repositories/database.repository.dart new file mode 100644 index 0000000000000..f9ee1426bbee7 --- /dev/null +++ b/mobile/lib/repositories/database.repository.dart @@ -0,0 +1,28 @@ +import 'dart:async'; + +import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:isar/isar.dart'; + +/// copied from Isar; needed to check if an async transaction is already active +const Symbol _zoneTxn = #zoneTxn; + +abstract class DatabaseRepository implements IDatabaseRepository { + final Isar db; + DatabaseRepository(this.db); + + bool get inTxn => Zone.current[_zoneTxn] != null; + + Future txn(Future Function() callback) => + inTxn ? callback() : transaction(callback); + + @override + Future transaction(Future Function() callback) => + db.writeTxn(callback); +} + +extension Asd on QueryBuilder { + QueryBuilder noOp() { + // ignore: invalid_use_of_protected_member + return QueryBuilder.apply(this, (query) => query); + } +} diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart new file mode 100644 index 0000000000000..5b42f66b02148 --- /dev/null +++ b/mobile/lib/repositories/download.repository.dart @@ -0,0 +1,68 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); + +class DownloadRepository implements IDownloadRepository { + @override + void Function(TaskStatusUpdate)? onImageDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + + @override + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + + @override + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadRepository() { + FileDownloader().registerCallbacks( + group: downloadGroupImage, + taskStatusCallback: (update) => onImageDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupVideo, + taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + + FileDownloader().registerCallbacks( + group: downloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + } + + @override + Future download(DownloadTask task) { + return FileDownloader().enqueue(task); + } + + @override + Future deleteAllTrackingRecords() { + return FileDownloader().database.deleteAllRecords(); + } + + @override + Future cancel(String id) { + return FileDownloader().cancelTaskWithId(id); + } + + @override + Future> getLiveVideoTasks() { + return FileDownloader().database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); + } + + @override + Future deleteRecordsWithIds(List ids) { + return FileDownloader().database.deleteRecordsWithIds(ids); + } +} diff --git a/mobile/lib/repositories/etag.repository.dart b/mobile/lib/repositories/etag.repository.dart new file mode 100644 index 0000000000000..9921b69f5ec0a --- /dev/null +++ b/mobile/lib/repositories/etag.repository.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final etagRepositoryProvider = + Provider((ref) => ETagRepository(ref.watch(dbProvider))); + +class ETagRepository extends DatabaseRepository implements IETagRepository { + ETagRepository(super.db); + + @override + Future> getAllIds() => db.eTags.where().idProperty().findAll(); + + @override + Future get(int id) => db.eTags.get(id); + + @override + Future upsertAll(List etags) => txn(() => db.eTags.putAll(etags)); + + @override + Future deleteByIds(List ids) => + txn(() => db.eTags.deleteAllById(ids)); + + @override + Future getById(String id) => db.eTags.getById(id); +} diff --git a/mobile/lib/repositories/exif_info.repository.dart b/mobile/lib/repositories/exif_info.repository.dart new file mode 100644 index 0000000000000..3ddb50104bea2 --- /dev/null +++ b/mobile/lib/repositories/exif_info.repository.dart @@ -0,0 +1,31 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; + +final exifInfoRepositoryProvider = + Provider((ref) => ExifInfoRepository(ref.watch(dbProvider))); + +class ExifInfoRepository extends DatabaseRepository + implements IExifInfoRepository { + ExifInfoRepository(super.db); + + @override + Future delete(int id) => txn(() => db.exifInfos.delete(id)); + + @override + Future get(int id) => db.exifInfos.get(id); + + @override + Future update(ExifInfo exifInfo) async { + await txn(() => db.exifInfos.put(exifInfo)); + return exifInfo; + } + + @override + Future> updateAll(List exifInfos) async { + await txn(() => db.exifInfos.putAll(exifInfos)); + return exifInfos; + } +} diff --git a/mobile/lib/repositories/file_media.repository.dart b/mobile/lib/repositories/file_media.repository.dart new file mode 100644 index 0000000000000..5612b378c3c9e --- /dev/null +++ b/mobile/lib/repositories/file_media.repository.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:photo_manager/photo_manager.dart' hide AssetType; + +final fileMediaRepositoryProvider = Provider((ref) => FileMediaRepository()); + +class FileMediaRepository implements IFileMediaRepository { + @override + Future saveImage( + Uint8List data, { + required String title, + String? relativePath, + }) async { + final entity = await PhotoManager.editor.saveImage( + data, + filename: title, + title: title, + relativePath: relativePath, + ); + return AssetMediaRepository.toAsset(entity); + } + + @override + Future saveLivePhoto({ + required File image, + required File video, + required String title, + }) async { + final entity = await PhotoManager.editor.darwin.saveLivePhoto( + imageFile: image, + videoFile: video, + title: title, + ); + return AssetMediaRepository.toAsset(entity); + } + + @override + Future saveVideo( + File file, { + required String title, + String? relativePath, + }) async { + final entity = await PhotoManager.editor.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + return AssetMediaRepository.toAsset(entity); + } + + @override + Future clearFileCache() => PhotoManager.clearFileCache(); + + @override + Future enableBackgroundAccess() => + PhotoManager.setIgnorePermissionCheck(true); + + @override + Future requestExtendedPermissions() => + PhotoManager.requestPermissionExtend(); +} diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart new file mode 100644 index 0000000000000..0b3d164ca3523 --- /dev/null +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -0,0 +1,51 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final partnerApiRepositoryProvider = Provider( + (ref) => PartnerApiRepository( + ref.watch(apiServiceProvider).partnersApi, + ), +); + +class PartnerApiRepository extends ApiRepository + implements IPartnerApiRepository { + final PartnersApi _api; + + PartnerApiRepository(this._api); + + @override + Future> getAll(Direction direction) async { + final response = await checkNull( + _api.getPartners( + direction == Direction.sharedByMe + ? PartnerDirection.by + : PartnerDirection.with_, + ), + ); + return response.map(User.fromPartnerDto).toList(); + } + + @override + Future create(String id) async { + final dto = await checkNull(_api.createPartner(id)); + return User.fromPartnerDto(dto); + } + + @override + Future delete(String id) => checkNull(_api.removePartner(id)); + + @override + Future update(String id, {required bool inTimeline}) async { + final dto = await checkNull( + _api.updatePartner( + id, + UpdatePartnerDto(inTimeline: inTimeline), + ), + ); + return User.fromPartnerDto(dto); + } +} diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart new file mode 100644 index 0000000000000..d324a03edbef8 --- /dev/null +++ b/mobile/lib/repositories/person_api.repository.dart @@ -0,0 +1,38 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final personApiRepositoryProvider = Provider( + (ref) => PersonApiRepository(ref.watch(apiServiceProvider).peopleApi), +); + +class PersonApiRepository extends ApiRepository + implements IPersonApiRepository { + final PeopleApi _api; + + PersonApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.getAllPeople()); + return dto.people.map(_toPerson).toList(); + } + + @override + Future update(String id, {String? name}) async { + final dto = await checkNull( + _api.updatePerson(id, PersonUpdateDto(name: name)), + ); + return _toPerson(dto); + } + + static Person _toPerson(PersonResponseDto dto) => Person( + birthDate: dto.birthDate, + id: dto.id, + isHidden: dto.isHidden, + name: dto.name, + thumbnailPath: dto.thumbnailPath, + ); +} diff --git a/mobile/lib/repositories/user.repository.dart b/mobile/lib/repositories/user.repository.dart new file mode 100644 index 0000000000000..fb4df84fe7c4a --- /dev/null +++ b/mobile/lib/repositories/user.repository.dart @@ -0,0 +1,63 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/database.repository.dart'; +import 'package:isar/isar.dart'; + +final userRepositoryProvider = + Provider((ref) => UserRepository(ref.watch(dbProvider))); + +class UserRepository extends DatabaseRepository implements IUserRepository { + UserRepository(super.db); + + @override + Future> getByIds(List ids) async => + (await db.users.getAllById(ids)).nonNulls.toList(); + + @override + Future get(String id) => db.users.getById(id); + + @override + Future> getAll({bool self = true, UserSort? sortBy}) { + final baseQuery = db.users.where(); + final int userId = Store.get(StoreKey.currentUser).isarId; + final QueryBuilder afterWhere = + self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId); + final QueryBuilder query; + switch (sortBy) { + case null: + query = afterWhere.noOp(); + case UserSort.id: + query = afterWhere.sortById(); + } + return query.findAll(); + } + + @override + Future update(User user) async { + await txn(() => db.users.put(user)); + return user; + } + + @override + Future me() => Future.value(Store.get(StoreKey.currentUser)); + + @override + Future deleteById(List ids) => txn(() => db.users.deleteAll(ids)); + + @override + Future> upsertAll(List users) async { + await txn(() => db.users.putAll(users)); + return users; + } + + @override + Future> getAllAccessible() => db.users + .filter() + .isPartnerSharedWithEqualTo(true) + .or() + .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .findAll(); +} diff --git a/mobile/lib/repositories/user_api.repository.dart b/mobile/lib/repositories/user_api.repository.dart new file mode 100644 index 0000000000000..9641c4e0e610e --- /dev/null +++ b/mobile/lib/repositories/user_api.repository.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/repositories/api.repository.dart'; +import 'package:openapi/api.dart'; + +final userApiRepositoryProvider = Provider( + (ref) => UserApiRepository( + ref.watch(apiServiceProvider).usersApi, + ), +); + +class UserApiRepository extends ApiRepository implements IUserApiRepository { + final UsersApi _api; + + UserApiRepository(this._api); + + @override + Future> getAll() async { + final dto = await checkNull(_api.searchUsers()); + return dto.map(User.fromSimpleUserDto).toList(); + } + + @override + Future<({String profileImagePath})> createProfileImage({ + required String name, + required Uint8List data, + }) async { + final response = await checkNull( + _api.createProfileImage( + MultipartFile.fromBytes('file', data, filename: name), + ), + ); + return (profileImagePath: response.profileImagePath); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 211c847726095..1f26e0d6de1ed 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -29,6 +29,7 @@ import 'package:immich_mobile/pages/common/splash_screen.page.dart'; import 'package:immich_mobile/pages/common/tab_controller.page.dart'; import 'package:immich_mobile/pages/editing/edit.page.dart'; import 'package:immich_mobile/pages/editing/crop.page.dart'; +import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; import 'package:immich_mobile/pages/library/library.page.dart'; @@ -63,7 +64,6 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:isar/isar.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -import 'package:photo_manager/photo_manager.dart' hide LatLng; part 'router.gr.dart'; @@ -136,6 +136,7 @@ class AppRouter extends RootStackRouter { ), AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), + AutoRoute(page: FilterImageRoute.page), AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 90fc4cb0fe96c..4b27ab155fc31 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -185,7 +185,7 @@ class AlbumOptionsRouteArgs { class AlbumPreviewRoute extends PageRouteInfo { AlbumPreviewRoute({ Key? key, - required AssetPathEntity album, + required Album album, List? children, }) : super( AlbumPreviewRoute.name, @@ -218,7 +218,7 @@ class AlbumPreviewRouteArgs { final Key? key; - final AssetPathEntity album; + final Album album; @override String toString() { @@ -755,6 +755,58 @@ class FavoritesRoute extends PageRouteInfo { ); } +/// generated route for +/// [FilterImagePage] +class FilterImageRoute extends PageRouteInfo { + FilterImageRoute({ + Key? key, + required Image image, + required Asset asset, + List? children, + }) : super( + FilterImageRoute.name, + args: FilterImageRouteArgs( + key: key, + image: image, + asset: asset, + ), + initialChildren: children, + ); + + static const String name = 'FilterImageRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return FilterImagePage( + key: args.key, + image: args.image, + asset: args.asset, + ); + }, + ); +} + +class FilterImageRouteArgs { + const FilterImageRouteArgs({ + this.key, + required this.image, + required this.asset, + }); + + final Key? key; + + final Image image; + + final Asset asset; + + @override + String toString() { + return 'FilterImageRouteArgs{key: $key, image: $image, asset: $asset}'; + } +} + /// generated route for /// [GalleryViewerPage] class GalleryViewerRoute extends PageRouteInfo { diff --git a/mobile/lib/services/activity.service.dart b/mobile/lib/services/activity.service.dart index 58af26e204663..5496041416558 100644 --- a/mobile/lib/services/activity.service.dart +++ b/mobile/lib/services/activity.service.dart @@ -1,41 +1,31 @@ -import 'package:immich_mobile/constants/errors.dart'; +import 'package:immich_mobile/interfaces/activity_api.interface.dart'; import 'package:immich_mobile/mixins/error_logger.mixin.dart'; import 'package:immich_mobile/models/activities/activity.model.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; class ActivityService with ErrorLoggerMixin { - final ApiService _apiService; + final IActivityApiRepository _activityApiRepository; @override final Logger logger = Logger("ActivityService"); - ActivityService(this._apiService); + ActivityService(this._activityApiRepository); Future> getAllActivities( String albumId, { String? assetId, }) async { return logError( - () async { - final list = await _apiService.activitiesApi - .getActivities(albumId, assetId: assetId); - return list != null ? list.map(Activity.fromDto).toList() : []; - }, + () => _activityApiRepository.getAll(albumId, assetId: assetId), defaultValue: [], errorMessage: "Failed to get all activities for album $albumId", ); } - Future getStatistics(String albumId, {String? assetId}) async { + Future getStatistics(String albumId, {String? assetId}) async { return logError( - () async { - final dto = await _apiService.activitiesApi - .getActivityStatistics(albumId, assetId: assetId); - return dto?.comments ?? 0; - }, - defaultValue: 0, + () => _activityApiRepository.getStats(albumId, assetId: assetId), + defaultValue: const ActivityStats(comments: 0), errorMessage: "Failed to statistics for album $albumId", ); } @@ -43,7 +33,7 @@ class ActivityService with ErrorLoggerMixin { Future removeActivity(String id) async { return logError( () async { - await _apiService.activitiesApi.deleteActivity(id); + await _activityApiRepository.delete(id); return true; }, defaultValue: false, @@ -58,22 +48,12 @@ class ActivityService with ErrorLoggerMixin { String? comment, }) async { return guardError( - () async { - final dto = await _apiService.activitiesApi.createActivity( - ActivityCreateDto( - albumId: albumId, - type: type == ActivityType.comment - ? ReactionType.comment - : ReactionType.like, - assetId: assetId, - comment: comment, - ), - ); - if (dto != null) { - return Activity.fromDto(dto); - } - throw NoResponseDtoError(); - }, + () => _activityApiRepository.create( + albumId, + type, + assetId: assetId, + comment: comment, + ), errorMessage: "Failed to create $type for album $albumId", ); } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index ef56f9bf6c12a..091049edb59f1 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -5,54 +5,64 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/album_api.interface.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart'; final albumServiceProvider = Provider( (ref) => AlbumService( - ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), - ref.watch(dbProvider), + ref.watch(entityServiceProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(backupRepositoryProvider), + ref.watch(albumMediaRepositoryProvider), + ref.watch(albumApiRepositoryProvider), ), ); class AlbumService { - final ApiService _apiService; final UserService _userService; final SyncService _syncService; - final Isar _db; + final EntityService _entityService; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IBackupRepository _backupAlbumRepository; + final IAlbumMediaRepository _albumMediaRepository; + final IAlbumApiRepository _albumApiRepository; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); AlbumService( - this._apiService, this._userService, this._syncService, - this._db, + this._entityService, + this._albumRepository, + this._assetRepository, + this._backupAlbumRepository, + this._albumMediaRepository, + this._albumApiRepository, ); - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { @@ -65,22 +75,18 @@ class AlbumService { final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { - final List excludedIds = - await excludedAlbumsQuery().idProperty().findAll(); - final List selectedIds = - await selectedAlbumsQuery().idProperty().findAll(); + final List excludedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.exclude); + final List selectedIds = await _backupAlbumRepository + .getIdsBySelection(BackupSelection.select); if (selectedIds.isEmpty) { - final numLocal = await _db.albums.where().localIdIsNotNull().count(); + final numLocal = await _albumRepository.count(local: true); if (numLocal > 0) { _syncService.removeAllLocalAlbumsAndAssets(); } return false; } - final List onDevice = - await PhotoManager.getAssetPathList( - hasAll: true, - filterOption: FilterOptionGroup(containsPathModified: true), - ); + final List onDevice = await _albumMediaRepository.getAll(); _log.info("Found ${onDevice.length} device albums"); Set? excludedAssets; if (excludedIds.isNotEmpty) { @@ -96,13 +102,15 @@ class AlbumService { _log.info("Found ${excludedAssets.length} assets to exclude"); } // remove all excluded albums - onDevice.removeWhere((e) => excludedIds.contains(e.id)); + onDevice.removeWhere((e) => excludedIds.contains(e.localId)); _log.info( "Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums", ); } final hasAll = selectedIds - .map((id) => onDevice.firstWhereOrNull((a) => a.id == id)) + .map( + (id) => onDevice.firstWhereOrNull((album) => album.localId == id), + ) .whereNotNull() .any((a) => a.isAll); if (hasAll) { @@ -114,7 +122,7 @@ class AlbumService { } } else { // keep only the explicitly selected albums - onDevice.removeWhere((e) => !selectedIds.contains(e.id)); + onDevice.removeWhere((e) => !selectedIds.contains(e.localId)); _log.info("'Recents' is not selected, keeping only selected albums"); } changes = @@ -128,15 +136,15 @@ class AlbumService { } Future> _loadExcludedAssetIds( - List albums, + List albums, List excludedAlbumIds, ) async { final Set result = HashSet(); - for (AssetPathEntity a in albums) { - if (excludedAlbumIds.contains(a.id)) { - final List assets = - await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff); - result.addAll(assets.map((e) => e.id)); + for (Album album in albums) { + if (excludedAlbumIds.contains(album.localId)) { + final assetIds = + await _albumMediaRepository.getAssetIds(album.localId!); + result.addAll(assetIds); } } return result; @@ -154,17 +162,11 @@ class AlbumService { bool changes = false; try { await _userService.refreshUsers(); - final List? serverAlbums = await _apiService.albumsApi - .getAllAlbums(shared: isShared ? true : null); - if (serverAlbums == null) { - return false; - } + final List serverAlbums = + await _albumApiRepository.getAll(shared: isShared ? true : null); changes = await _syncService.syncRemoteAlbumsToDb( serverAlbums, isShared: isShared, - loadDetails: (dto) async => dto.assetCount == dto.assets.length - ? dto - : (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto, ); } finally { _remoteCompleter.complete(changes); @@ -178,30 +180,13 @@ class AlbumService { Iterable assets, [ Iterable sharedUsers = const [], ]) async { - try { - AlbumResponseDto? remote = await _apiService.albumsApi.createAlbum( - CreateAlbumDto( - albumName: albumName, - assetIds: assets.map((asset) => asset.remoteId!).toList(), - albumUsers: sharedUsers - .map( - (e) => AlbumUserCreateDto( - userId: e.id, - role: AlbumUserRole.editor, - ), - ) - .toList(), - ), - ); - if (remote != null) { - Album album = await Album.remote(remote); - await _db.writeTxn(() => _db.albums.store(album)); - return album; - } - } catch (e) { - debugPrint("Error createSharedAlbum ${e.toString()}"); - } - return null; + final Album album = await _albumApiRepository.create( + albumName, + assetIds: assets.map((asset) => asset.remoteId!), + sharedUserIds: sharedUsers.map((user) => user.id), + ); + await _entityService.fillAlbumWithDatabaseEntities(album); + return _albumRepository.create(album); } /* @@ -212,8 +197,7 @@ class AlbumService { for (int round = 0;; round++) { final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; - if (null == - await _db.albums.filter().nameEqualTo(proposedName).findFirst()) { + if (null == await _albumRepository.getByName(proposedName)) { return proposedName; } } @@ -234,32 +218,21 @@ class AlbumService { Album album, ) async { try { - var response = await _apiService.albumsApi.addAssetsToAlbum( + final result = await _albumApiRepository.addAssets( album.remoteId!, - BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), + assets.map((asset) => asset.remoteId!), ); - if (response != null) { - List successAssets = []; - List duplicatedAssets = []; - - for (final result in response) { - if (result.success) { - successAssets - .add(assets.firstWhere((asset) => asset.remoteId == result.id)); - } else if (!result.success && - result.error == BulkIdResponseDtoErrorEnum.duplicate) { - duplicatedAssets.add(result.id); - } - } + final List addedAssets = result.added + .map((id) => assets.firstWhere((asset) => asset.remoteId == id)) + .toList(); - await _updateAssets(album.id, add: successAssets); + await _updateAssets(album.id, add: addedAssets); - return AlbumAddAssetsResponse( - alreadyInAlbum: duplicatedAssets, - successfullyAdded: successAssets.length, - ); - } + return AlbumAddAssetsResponse( + alreadyInAlbum: result.duplicates, + successfullyAdded: addedAssets.length, + ); } catch (e) { debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); } @@ -268,45 +241,28 @@ class AlbumService { Future _updateAssets( int albumId, { - Iterable add = const [], - Iterable remove = const [], - }) { - return _db.writeTxn(() async { - final album = await _db.albums.get(albumId); - if (album == null) return; - await album.assets.update(link: add, unlink: remove); - album.startDate = - await album.assets.filter().fileCreatedAtProperty().min(); - album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); - album.lastModifiedAssetTimestamp = - await album.assets.filter().updatedAtProperty().max(); - await _db.albums.put(album); - }); - } + List add = const [], + List remove = const [], + }) => + _albumRepository.transaction(() async { + final album = await _albumRepository.get(albumId); + if (album == null) return; + await _albumRepository.addAssets(album, add); + await _albumRepository.removeAssets(album, remove); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); + }); Future addAdditionalUserToAlbum( List sharedUserIds, Album album, ) async { try { - final List albumUsers = sharedUserIds - .map((userId) => AlbumUserAddDto(userId: userId)) - .toList(); - - final result = await _apiService.albumsApi.addUsersToAlbum( - album.remoteId!, - AddUsersDto(albumUsers: albumUsers), - ); - if (result != null) { - album.sharedUsers - .addAll((await _db.users.getAllById(sharedUserIds)).cast()); - album.shared = result.shared; - await _db.writeTxn(() async { - await _db.albums.put(album); - await album.sharedUsers.save(); - }); - return true; - } + final updatedAlbum = + await _albumApiRepository.addUsers(album.remoteId!, sharedUserIds); + await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum); + await _albumRepository.update(updatedAlbum); + return true; } catch (e) { debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); } @@ -315,15 +271,13 @@ class AlbumService { Future setActivityEnabled(Album album, bool enabled) async { try { - final result = await _apiService.albumsApi.updateAlbumInfo( + final updatedAlbum = await _albumApiRepository.update( album.remoteId!, - UpdateAlbumDto(isActivityEnabled: enabled), + activityEnabled: enabled, ); - if (result != null) { - album.activityEnabled = enabled; - await _db.writeTxn(() => _db.albums.put(album)); - return true; - } + await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum); + await _albumRepository.update(updatedAlbum); + return true; } catch (e) { debugPrint("Error setActivityEnabled ${e.toString()}"); } @@ -334,27 +288,27 @@ class AlbumService { try { final userId = Store.get(StoreKey.currentUser).isarId; if (album.owner.value?.isarId == userId) { - await _apiService.albumsApi.deleteAlbum(album.remoteId!); + await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); - await _db.writeTxn(() => _db.albums.delete(album.id)); - final List albums = - await _db.albums.filter().sharedEqualTo(true).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); + await _albumRepository.delete(album.id); + + final List albums = await _albumRepository.getAll(shared: true); final List existing = []; - for (Album a in albums) { + for (Album album in albums) { existing.addAll( - await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]), ); } final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); + await _assetRepository.deleteById(idsToRemove); } } else { - await _db.writeTxn(() => _db.albums.delete(album.id)); + await _albumRepository.delete(album.id); } return true; } catch (e) { @@ -365,7 +319,7 @@ class AlbumService { Future leaveAlbum(Album album) async { try { - await _apiService.albumsApi.removeUserFromAlbum(album.remoteId!, "me"); + await _albumApiRepository.removeUser(album.remoteId!, userId: "me"); return true; } catch (e) { debugPrint("Error leaveAlbum ${e.toString()}"); @@ -378,21 +332,14 @@ class AlbumService { Iterable assets, ) async { try { - final response = await _apiService.albumsApi.removeAssetFromAlbum( + final result = await _albumApiRepository.removeAssets( album.remoteId!, - BulkIdsDto( - ids: assets.map((asset) => asset.remoteId!).toList(), - ), + assets.map((asset) => asset.remoteId!), ); - if (response != null) { - final toRemove = response.every((e) => e.success) - ? assets - : response - .where((e) => e.success) - .map((e) => assets.firstWhere((a) => a.remoteId == e.id)); - await _updateAssets(album.id, remove: toRemove); - return true; - } + final toRemove = result.removed + .map((id) => assets.firstWhere((asset) => asset.remoteId == id)); + await _updateAssets(album.id, remove: toRemove.toList()); + return true; } catch (e) { debugPrint("Error removeAssetFromAlbum ${e.toString()}"); } @@ -404,18 +351,16 @@ class AlbumService { User user, ) async { try { - await _apiService.albumsApi.removeUserFromAlbum( + await _albumApiRepository.removeUser( album.remoteId!, - user.id, + userId: user.id, ); album.sharedUsers.remove(user); - await _db.writeTxn(() async { - await album.sharedUsers.update(unlink: [user]); - final a = await _db.albums.get(album.id); - // trigger watcher - await _db.albums.put(a!); - }); + await _albumRepository.removeUsers(album, [user]); + final a = await _albumRepository.get(album.id); + // trigger watcher + await _albumRepository.update(a!); return true; } catch (e) { @@ -429,15 +374,12 @@ class AlbumService { String newAlbumTitle, ) async { try { - await _apiService.albumsApi.updateAlbumInfo( + album = await _albumApiRepository.update( album.remoteId!, - UpdateAlbumDto( - albumName: newAlbumTitle, - ), + name: newAlbumTitle, ); - album.name = newAlbumTitle; - await _db.writeTxn(() => _db.albums.put(album)); - + await _entityService.fillAlbumWithDatabaseEntities(album); + await _albumRepository.update(album); return true; } catch (e) { debugPrint("Error changeTitleAlbum ${e.toString()}"); @@ -445,14 +387,8 @@ class AlbumService { } } - Future getAlbumByName(String name, bool remoteOnly) async { - return _db.albums - .filter() - .optional(remoteOnly, (q) => q.localIdIsNull()) - .nameEqualTo(name) - .sharedEqualTo(false) - .findFirst(); - } + Future getAlbumByName(String name, bool remoteOnly) => + _albumRepository.getByName(name, remote: remoteOnly ? true : null); /// /// Add the uploaded asset to the selected albums @@ -464,12 +400,8 @@ class AlbumService { for (final albumName in albumNames) { Album? album = await getAlbumByName(albumName, true); album ??= await createAlbum(albumName, []); - if (album != null && album.remoteId != null) { - await _apiService.albumsApi.addAssetsToAlbum( - album.remoteId!, - BulkIdsDto(ids: assetIds), - ); + await _albumApiRepository.addAssets(album.remoteId!, assetIds); } } } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 4a3cfb19a2a28..515023d163f1c 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -97,27 +97,13 @@ class ApiService implements Authentication { } Future _isEndpointAvailable(String serverUrl) async { - final Client client = Client(); - if (!serverUrl.endsWith('/api')) { serverUrl += '/api'; } try { - final response = await client - .get( - Uri.parse("$serverUrl/server-info/ping"), - headers: getRequestHeaders(), - ) - .timeout(const Duration(seconds: 5)); - - _log.info("Pinging server with response code ${response.statusCode}"); - if (response.statusCode != 200) { - _log.severe( - "Server Gateway Error: ${response.body} - Cannot communicate to the server", - ); - return false; - } + await setEndpoint(serverUrl); + await serverInfoApi.pingServer().timeout(Duration(seconds: 5)); } on TimeoutException catch (_) { return false; } on SocketException catch (_) { diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index c4f258e259129..b2cad4dc828eb 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,66 +1,85 @@ -// ignore_for_file: null_argument_to_non_null_type - import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:openapi/api.dart'; final assetServiceProvider = Provider( (ref) => AssetService( + ref.watch(assetApiRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ref.watch(backupRepositoryProvider), ref.watch(apiServiceProvider), ref.watch(syncServiceProvider), ref.watch(userServiceProvider), ref.watch(backupServiceProvider), ref.watch(albumServiceProvider), - ref.watch(dbProvider), ), ); class AssetService { + final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _etagRepository; + final IBackupRepository _backupRepository; final ApiService _apiService; final SyncService _syncService; final UserService _userService; final BackupService _backupService; final AlbumService _albumService; final log = Logger('AssetService'); - final Isar _db; AssetService( + this._assetApiRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._etagRepository, + this._backupRepository, this._apiService, this._syncService, this._userService, this._backupService, this._albumService, - this._db, ); /// Checks the server for updated assets and updates the local database if /// required. Returns `true` if there were any changes. Future refreshRemoteAssets() async { - final syncedUserIds = await _db.eTags.where().idProperty().findAll(); + final syncedUserIds = await _etagRepository.getAllIds(); final List syncedUsers = syncedUserIds.isEmpty ? [] - : await _db.users - .where() - .anyOf(syncedUserIds, (q, id) => q.idEqualTo(id)) - .findAll(); + : await _userRepository.getByIds(syncedUserIds); final Stopwatch sw = Stopwatch()..start(); final bool changes = await _syncService.syncRemoteAssetsToDb( users: syncedUsers, @@ -165,7 +184,7 @@ class AssetService { /// Loads the exif information from the database. If there is none, loads /// the exif info from the server (remote assets only) Future loadExif(Asset a) async { - a.exifInfo ??= await _db.exifInfos.get(a.id); + a.exifInfo ??= await _exifInfoRepository.get(a.id); // fileSize is always filled on the server but not set on client if (a.exifInfo?.fileSize == null) { if (a.isRemote) { @@ -175,7 +194,7 @@ class AssetService { a.exifInfo = newExif; if (newExif != a.exifInfo) { if (a.isInDb) { - _db.writeTxn(() => a.put(_db)); + _assetRepository.transaction(() => _assetRepository.update(a)); } else { debugPrint("[loadExif] parameter Asset is not from DB!"); } @@ -204,7 +223,7 @@ class AssetService { ); } - Future> changeFavoriteStatus( + Future> changeFavoriteStatus( List assets, bool isFavorite, ) async { @@ -220,11 +239,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing favorite status", error, stack); - return Future.value(null); + return []; } } - Future> changeArchiveStatus( + Future> changeArchiveStatus( List assets, bool isArchived, ) async { @@ -240,11 +259,11 @@ class AssetService { return assets; } catch (error, stack) { log.severe("Error while changing archive status", error, stack); - return Future.value(null); + return []; } } - Future> changeDateTime( + Future?> changeDateTime( List assets, String updatedDt, ) async { @@ -268,7 +287,7 @@ class AssetService { } } - Future> changeLocation( + Future?> changeLocation( List assets, LatLng location, ) async { @@ -297,10 +316,10 @@ class AssetService { Future syncUploadedAssetToAlbums() async { try { - final [selectedAlbums, excludedAlbums] = await Future.wait([ - _backupService.selectedAlbumsQuery().findAll(), - _backupService.excludedAlbumsQuery().findAll(), - ]); + final selectedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await _backupRepository.getAllBySelection(BackupSelection.exclude); final candidates = await _backupService.buildUploadCandidates( selectedAlbums, @@ -309,19 +328,18 @@ class AssetService { ); await refreshRemoteAssets(); - final remoteAssets = await _db.assets - .where() - .localIdIsNotNull() - .filter() - .remoteIdIsNotNull() - .findAll(); + final owner = await _userRepository.me(); + final remoteAssets = await _assetRepository.getAll( + ownerId: owner.isarId, + state: AssetState.merged, + ); /// Map Map> assetToAlbums = {}; for (BackupCandidate candidate in candidates) { final asset = remoteAssets.firstWhereOrNull( - (a) => a.localId == candidate.asset.id, + (a) => a.localId == candidate.asset.localId, ); if (asset != null) { @@ -342,4 +360,46 @@ class AssetService { log.severe("Error while syncing uploaded asset to albums", error, stack); } } + + Future setDescription( + Asset asset, + String newDescription, + ) async { + final remoteAssetId = asset.remoteId; + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (remoteAssetId == null || localExifId == null) { + return; + } + + final result = await _assetApiRepository.update( + remoteAssetId, + description: newDescription, + ); + + final description = result.exifInfo?.description; + + if (description != null) { + var exifInfo = await _exifInfoRepository.get(localExifId); + + if (exifInfo != null) { + exifInfo.description = description; + await _exifInfoRepository.update(exifInfo); + } + } + } + + Future getDescription(Asset asset) async { + final localExifId = asset.exifInfo?.id; + + // Guard [remoteAssetId] and [localExifId] null + if (localExifId == null) { + return ""; + } + + final exifInfo = await _exifInfoRepository.get(localExifId); + + return exifInfo?.description ?? ""; + } } diff --git a/mobile/lib/services/asset_description.service.dart b/mobile/lib/services/asset_description.service.dart deleted file mode 100644 index 196e29dc6a97d..0000000000000 --- a/mobile/lib/services/asset_description.service.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; -import 'package:openapi/api.dart'; - -class AssetDescriptionService { - AssetDescriptionService(this._db, this._api); - - final Isar _db; - final ApiService _api; - - Future setDescription( - Asset asset, - String newDescription, - ) async { - final remoteAssetId = asset.remoteId; - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (remoteAssetId == null || localExifId == null) { - return; - } - - final result = await _api.assetsApi.updateAsset( - remoteAssetId, - UpdateAssetDto(description: newDescription), - ); - - final description = result?.exifInfo?.description; - - if (description != null) { - var exifInfo = await _db.exifInfos.get(localExifId); - - if (exifInfo != null) { - exifInfo.description = description; - await _db.writeTxn( - () => _db.exifInfos.put(exifInfo), - ); - } - } - } - - String getAssetDescription(Asset asset) { - final localExifId = asset.exifInfo?.id; - - // Guard [remoteAssetId] and [localExifId] null - if (localExifId == null) { - return ""; - } - - final exifInfo = _db.exifInfos.getSync(localExifId); - - return exifInfo?.description ?? ""; - } -} - -final assetDescriptionServiceProvider = Provider( - (ref) => AssetDescriptionService( - ref.watch(dbProvider), - ref.watch(apiServiceProvider), - ), -); diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index fc3feb174d582..3959e2a6edc2b 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -9,10 +9,24 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/main.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/repositories/backup.repository.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; @@ -22,15 +36,13 @@ import 'package:immich_mobile/services/backup.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/services/partner.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/utils/backup_progress.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/http_ssl_cert_override.dart'; -import 'package:isar/isar.dart'; import 'package:path_provider_ios/path_provider_ios.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backgroundServiceProvider = Provider( (ref) => BackgroundService(), @@ -347,30 +359,78 @@ class BackgroundService { } Future _onAssetsChanged() async { - final Isar db = await loadDb(); + final db = await loadDb(); HttpOverrides.global = HttpSSLCertOverride(); ApiService apiService = ApiService(); apiService.setAccessToken(Store.get(StoreKey.accessToken)); AppSettingsService settingService = AppSettingsService(); AppSettingsService settingsService = AppSettingsService(); - PartnerService partnerService = PartnerService(apiService, db); - HashService hashService = HashService(db, this); - SyncService syncSerive = SyncService(db, hashService); - UserService userService = - UserService(apiService, db, syncSerive, partnerService); - AlbumService albumService = - AlbumService(apiService, userService, syncSerive, db); - BackupService backupService = - BackupService(apiService, db, settingService, albumService); - - final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); - final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync(); + AlbumRepository albumRepository = AlbumRepository(db); + AssetRepository assetRepository = AssetRepository(db); + BackupRepository backupRepository = BackupRepository(db); + ExifInfoRepository exifInfoRepository = ExifInfoRepository(db); + ETagRepository eTagRepository = ETagRepository(db); + AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); + FileMediaRepository fileMediaRepository = FileMediaRepository(); + AssetMediaRepository assetMediaRepository = AssetMediaRepository(); + UserRepository userRepository = UserRepository(db); + UserApiRepository userApiRepository = + UserApiRepository(apiService.usersApi); + AlbumApiRepository albumApiRepository = + AlbumApiRepository(apiService.albumsApi); + PartnerApiRepository partnerApiRepository = + PartnerApiRepository(apiService.partnersApi); + HashService hashService = + HashService(assetRepository, this, albumMediaRepository); + EntityService entityService = + EntityService(assetRepository, userRepository); + SyncService syncSerive = SyncService( + hashService, + entityService, + albumMediaRepository, + albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + ); + UserService userService = UserService( + partnerApiRepository, + userApiRepository, + userRepository, + syncSerive, + ); + AlbumService albumService = AlbumService( + userService, + syncSerive, + entityService, + albumRepository, + assetRepository, + backupRepository, + albumMediaRepository, + albumApiRepository, + ); + BackupService backupService = BackupService( + apiService, + settingService, + albumService, + albumMediaRepository, + fileMediaRepository, + assetRepository, + assetMediaRepository, + ); + + final selectedAlbums = + await backupRepository.getAllBySelection(BackupSelection.select); + final excludedAlbums = + await backupRepository.getAllBySelection(BackupSelection.exclude); if (selectedAlbums.isEmpty) { return true; } - await PhotoManager.setIgnorePermissionCheck(true); + await fileMediaRepository.enableBackgroundAccess(); do { final bool backupOk = await _runBackup( @@ -383,28 +443,28 @@ class BackgroundService { await Store.delete(StoreKey.backupFailedSince); final backupAlbums = [...selectedAlbums, ...excludedAlbums]; backupAlbums.sortBy((e) => e.id); - db.writeTxnSync(() { - final dbAlbums = db.backupAlbums.where().sortById().findAllSync(); - final List toDelete = []; - final List toUpsert = []; - // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state - diffSortedListsSync( - dbAlbums, - backupAlbums, - compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), - both: (BackupAlbum a, BackupAlbum b) { - a.lastBackup = a.lastBackup.isAfter(b.lastBackup) - ? a.lastBackup - : b.lastBackup; - toUpsert.add(a); - return true; - }, - onlyFirst: (BackupAlbum a) => toUpsert.add(a), - onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), - ); - db.backupAlbums.deleteAllSync(toDelete); - db.backupAlbums.putAllSync(toUpsert); - }); + + final dbAlbums = + await backupRepository.getAll(sort: BackupAlbumSort.id); + final List toDelete = []; + final List toUpsert = []; + // stores the most recent `lastBackup` per album but always keeps the `selection` from the most recent DB state + diffSortedListsSync( + dbAlbums, + backupAlbums, + compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id), + both: (BackupAlbum a, BackupAlbum b) { + a.lastBackup = a.lastBackup.isAfter(b.lastBackup) + ? a.lastBackup + : b.lastBackup; + toUpsert.add(a); + return true; + }, + onlyFirst: (BackupAlbum a) => toUpsert.add(a), + onlySecond: (BackupAlbum b) => toDelete.add(b.isarId), + ); + await backupRepository.deleteAll(toDelete); + await backupRepository.updateAll(toUpsert); } else if (Store.tryGet(StoreKey.backupFailedSince) == null) { Store.put(StoreKey.backupFailedSince, DateTime.now()); return false; diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 858499443ee1d..a0b6bf16c2304 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -6,48 +6,64 @@ import 'package:cancellation_token_http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; -import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/error_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; import 'package:permission_handler/permission_handler.dart' as pm; -import 'package:photo_manager/photo_manager.dart'; +import 'package:photo_manager/photo_manager.dart' show PMProgressHandler; final backupServiceProvider = Provider( (ref) => BackupService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), + ref.watch(albumMediaRepositoryProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(assetMediaRepositoryProvider), ), ); class BackupService { final httpClient = http.Client(); final ApiService _apiService; - final Isar _db; final Logger _log = Logger("BackupService"); final AppSettingsService _appSetting; final AlbumService _albumService; + final IAlbumMediaRepository _albumMediaRepository; + final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; + final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, - this._db, this._appSetting, this._albumService, + this._albumMediaRepository, + this._fileMediaRepository, + this._assetRepository, + this._assetMediaRepository, ); Future?> getDeviceBackupAsset() async { @@ -61,24 +77,17 @@ class BackupService { } } - Future _saveDuplicatedAssetIds(List deviceAssetIds) { - final duplicates = deviceAssetIds.map((id) => DuplicatedAsset(id)).toList(); - return _db.writeTxn(() => _db.duplicatedAssets.putAll(duplicates)); - } + Future _saveDuplicatedAssetIds(List deviceAssetIds) => + _assetRepository.transaction( + () => _assetRepository.upsertDuplicatedAssets(deviceAssetIds), + ); /// Get duplicated asset id from database Future> getDuplicatedAssetIds() async { - final duplicates = await _db.duplicatedAssets.where().findAll(); - return duplicates.map((e) => e.id).toSet(); + final duplicates = await _assetRepository.getAllDuplicatedAssetIds(); + return duplicates.toSet(); } - QueryBuilder - selectedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.select); - QueryBuilder - excludedAlbumsQuery() => - _db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude); - /// Returns all assets newer than the last successful backup per album /// if `useTimeFilter` is set to true, all assets will be returned Future> buildUploadCandidates( @@ -86,44 +95,17 @@ class BackupService { List excludedBackupAlbums, { bool useTimeFilter = true, }) async { - final filter = FilterOptionGroup( - containsPathModified: true, - orders: [const OrderOption(type: OrderOptionType.updateDate)], - // title is needed to create Assets - imageOption: const FilterOption(needTitle: true), - videoOption: const FilterOption(needTitle: true), - ); final now = DateTime.now(); - final List selectedAlbums = - await _loadAlbumsWithTimeFilter( - selectedBackupAlbums, - filter, - now, - useTimeFilter: useTimeFilter, - ); - - if (selectedAlbums.every((e) => e == null)) { - return {}; - } - - final List excludedAlbums = - await _loadAlbumsWithTimeFilter( - excludedBackupAlbums, - filter, - now, - useTimeFilter: useTimeFilter, - ); - final Set toAdd = await _fetchAssetsAndUpdateLastBackup( - selectedAlbums, selectedBackupAlbums, now, useTimeFilter: useTimeFilter, ); + if (toAdd.isEmpty) return {}; + final Set toRemove = await _fetchAssetsAndUpdateLastBackup( - excludedAlbums, excludedBackupAlbums, now, useTimeFilter: useTimeFilter, @@ -132,92 +114,62 @@ class BackupService { return toAdd.difference(toRemove); } - Future> _loadAlbumsWithTimeFilter( - List albums, - FilterOptionGroup filter, - DateTime now, { - bool useTimeFilter = true, - }) async { - List result = []; - for (BackupAlbum backupAlbum in albums) { - try { - final optionGroup = useTimeFilter - ? filter.copyWith( - updateTimeCond: DateTimeCond( - // subtract 2 seconds to prevent missing assets due to rounding issues - min: backupAlbum.lastBackup - .subtract(const Duration(seconds: 2)), - max: now, - ), - ) - : filter; - - final AssetPathEntity album = - await AssetPathEntity.obtainPathFromProperties( - id: backupAlbum.id, - optionGroup: optionGroup, - maxDateTimeToNow: false, - ); - - result.add(album); - } on StateError { - // either there are no assets matching the filter criteria OR the album no longer exists - } - } - - return result; - } - Future> _fetchAssetsAndUpdateLastBackup( - List localAlbums, List backupAlbums, DateTime now, { bool useTimeFilter = true, }) async { - Set candidate = {}; + Set candidates = {}; - for (int i = 0; i < localAlbums.length; i++) { - final localAlbum = localAlbums[i]; - if (localAlbum == null) { + for (final BackupAlbum backupAlbum in backupAlbums) { + final Album localAlbum; + try { + localAlbum = await _albumMediaRepository.get(backupAlbum.id); + } on StateError { + // the album no longer exists continue; } if (useTimeFilter && - localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) == - true) { + localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) { + continue; + } + final List assets; + try { + assets = await _albumMediaRepository.getAssets( + backupAlbum.id, + modifiedFrom: useTimeFilter + ? + // subtract 2 seconds to prevent missing assets due to rounding issues + backupAlbum.lastBackup.subtract(const Duration(seconds: 2)) + : null, + modifiedUntil: useTimeFilter ? now : null, + ); + } on StateError { + // either there are no assets matching the filter criteria OR the album no longer exists continue; } - - final assets = await localAlbum.getAssetListRange( - start: 0, - end: await localAlbum.assetCountAsync, - ); // Add album's name to the asset info for (final asset in assets) { List albumNames = [localAlbum.name]; - final existingAsset = candidate.firstWhereOrNull( - (a) => a.asset.id == asset.id, + final existingAsset = candidates.firstWhereOrNull( + (candidate) => candidate.asset.localId == asset.localId, ); if (existingAsset != null) { albumNames.addAll(existingAsset.albumNames); - candidate.remove(existingAsset); + candidates.remove(existingAsset); } - candidate.add( - BackupCandidate( - asset: asset, - albumNames: albumNames, - ), - ); + candidates.add(BackupCandidate(asset: asset, albumNames: albumNames)); } - backupAlbums[i].lastBackup = now; + backupAlbum.lastBackup = now; } - return candidate; + return candidates; } /// Returns a new list of assets not yet uploaded @@ -230,7 +182,7 @@ class BackupService { final Set duplicatedAssetIds = await getDuplicatedAssetIds(); candidates.removeWhere( - (candidate) => duplicatedAssetIds.contains(candidate.asset.id), + (candidate) => duplicatedAssetIds.contains(candidate.asset.localId), ); if (candidates.isEmpty) { @@ -243,7 +195,7 @@ class BackupService { final CheckExistingAssetsResponseDto? duplicates = await _apiService.assetsApi.checkExistingAssets( CheckExistingAssetsDto( - deviceAssetIds: candidates.map((c) => c.asset.id).toList(), + deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(), deviceId: deviceId, ), ); @@ -259,7 +211,7 @@ class BackupService { } if (existing.isNotEmpty) { - candidates.removeWhere((c) => existing.contains(c.asset.id)); + candidates.removeWhere((c) => existing.contains(c.asset.localId)); } return candidates; @@ -278,7 +230,7 @@ class BackupService { // DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS if (Platform.isIOS) { - await PhotoManager.requestPermissionExtend(); + await _fileMediaRepository.requestExtendedPermissions(); } return true; @@ -289,9 +241,9 @@ class BackupService { List _sortPhotosFirst(List candidates) { return candidates.sorted( (a, b) { - final cmp = a.asset.typeInt - b.asset.typeInt; + final cmp = a.asset.type.index - b.asset.type.index; if (cmp != 0) return cmp; - return a.asset.createDateTime.compareTo(b.asset.createDateTime); + return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt); }, ); } @@ -325,13 +277,13 @@ class BackupService { } for (final candidate in candidates) { - final AssetEntity entity = candidate.asset; + final Asset asset = candidate.asset; File? file; File? livePhotoFile; try { final isAvailableLocally = - await entity.isLocallyAvailable(isOrigin: true); + await asset.local!.isLocallyAvailable(isOrigin: true); // Handle getting files from iCloud if (!isAvailableLocally && Platform.isIOS) { @@ -342,39 +294,43 @@ class BackupService { onCurrentAsset( CurrentUploadAsset( - id: entity.id, - fileCreatedAt: entity.createDateTime.year == 1970 - ? entity.modifiedDateTime - : entity.createDateTime, - fileName: await entity.titleAsync, - fileType: _getAssetType(entity.type), + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt.year == 1970 + ? asset.fileModifiedAt + : asset.fileCreatedAt, + fileName: asset.fileName, + fileType: _getAssetType(asset.type), iCloudAsset: true, ), ); - file = await entity.loadFile(progressHandler: pmProgressHandler); - if (entity.isLivePhoto) { - livePhotoFile = await entity.loadFile( + file = + await asset.local!.loadFile(progressHandler: pmProgressHandler); + if (asset.local!.isLivePhoto) { + livePhotoFile = await asset.local!.loadFile( withSubtype: true, progressHandler: pmProgressHandler, ); } } else { - if (entity.type == AssetType.video) { - file = await entity.originFile; + if (asset.type == AssetType.video) { + file = await asset.local!.originFile; } else { - file = await entity.originFile.timeout(const Duration(seconds: 5)); - if (entity.isLivePhoto) { - livePhotoFile = await entity.originFileWithSubtype + file = await asset.local!.originFile + .timeout(const Duration(seconds: 5)); + if (asset.local!.isLivePhoto) { + livePhotoFile = await asset.local!.originFileWithSubtype .timeout(const Duration(seconds: 5)); } } } if (file != null) { - String originalFileName = await entity.titleAsync; + String? originalFileName = + await _assetMediaRepository.getOriginalFilename(asset.localId!); + originalFileName ??= asset.fileName; - if (entity.isLivePhoto) { + if (asset.local!.isLivePhoto) { if (livePhotoFile == null) { _log.warning( "Failed to obtain motion part of the livePhoto - $originalFileName", @@ -398,31 +354,31 @@ class BackupService { baseRequest.headers.addAll(ApiService.getRequestHeaders()); baseRequest.headers["Transfer-Encoding"] = "chunked"; - baseRequest.fields['deviceAssetId'] = entity.id; + baseRequest.fields['deviceAssetId'] = asset.localId!; baseRequest.fields['deviceId'] = deviceId; baseRequest.fields['fileCreatedAt'] = - entity.createDateTime.toUtc().toIso8601String(); + asset.fileCreatedAt.toUtc().toIso8601String(); baseRequest.fields['fileModifiedAt'] = - entity.modifiedDateTime.toUtc().toIso8601String(); - baseRequest.fields['isFavorite'] = entity.isFavorite.toString(); - baseRequest.fields['duration'] = entity.videoDuration.toString(); + asset.fileModifiedAt.toUtc().toIso8601String(); + baseRequest.fields['isFavorite'] = asset.isFavorite.toString(); + baseRequest.fields['duration'] = asset.duration.toString(); baseRequest.files.add(assetRawUploadData); onCurrentAsset( CurrentUploadAsset( - id: entity.id, - fileCreatedAt: entity.createDateTime.year == 1970 - ? entity.modifiedDateTime - : entity.createDateTime, + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt.year == 1970 + ? asset.fileModifiedAt + : asset.fileCreatedAt, fileName: originalFileName, - fileType: _getAssetType(entity.type), + fileType: _getAssetType(asset.type), fileSize: file.lengthSync(), iCloudAsset: false, ), ); String? livePhotoVideoId; - if (entity.isLivePhoto && livePhotoFile != null) { + if (asset.local!.isLivePhoto && livePhotoFile != null) { livePhotoVideoId = await uploadLivePhotoVideo( originalFileName, livePhotoFile, @@ -448,16 +404,16 @@ class BackupService { final errorMessage = error['message'] ?? error['error']; debugPrint( - "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}", + "Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}", ); onError( ErrorUploadAsset( - asset: entity, - id: entity.id, - fileCreatedAt: entity.createDateTime, + asset: asset, + id: asset.localId!, + fileCreatedAt: asset.fileCreatedAt, fileName: originalFileName, - fileType: _getAssetType(entity.type), + fileType: _getAssetType(candidate.asset.type), errorMessage: errorMessage, ), ); @@ -473,7 +429,7 @@ class BackupService { bool isDuplicate = false; if (response.statusCode == 200) { isDuplicate = true; - duplicatedAssetIds.add(entity.id); + duplicatedAssetIds.add(asset.localId!); } onSuccess( diff --git a/mobile/lib/services/backup_verification.service.dart b/mobile/lib/services/backup_verification.service.dart index c7cd134cb1a1e..82cfb8347a975 100644 --- a/mobile/lib/services/backup_verification.service.dart +++ b/mobile/lib/services/backup_verification.service.dart @@ -8,39 +8,46 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; -import 'package:photo_manager/photo_manager.dart' show PhotoManager; /// Finds duplicates originating from missing EXIF information class BackupVerificationService { - final Isar _db; + final IFileMediaRepository _fileMediaRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; - BackupVerificationService(this._db); + BackupVerificationService( + this._fileMediaRepository, + this._assetRepository, + this._exifInfoRepository, + ); /// Returns at most [limit] assets that were backed up without exif Future> findWronglyBackedUpAssets({int limit = 100}) async { final owner = Store.get(StoreKey.currentUser).isarId; - final List onlyLocal = await _db.assets - .where() - .remoteIdIsNull() - .filter() - .ownerIdEqualTo(owner) - .localIdIsNotNull() - .findAll(); - final List remoteMatches = await _getMatches( - _db.assets.where().localIdIsNull().filter().remoteIdIsNotNull(), - owner, - onlyLocal, - limit, + final List onlyLocal = await _assetRepository.getAll( + ownerId: owner, + state: AssetState.local, + limit: limit, ); - final List localMatches = await _getMatches( - _db.assets.where().remoteIdIsNull().filter().localIdIsNotNull(), - owner, - remoteMatches, - limit, + final List remoteMatches = await _assetRepository.getMatches( + assets: onlyLocal, + ownerId: owner, + state: AssetState.remote, + limit: limit, + ); + final List localMatches = await _assetRepository.getMatches( + assets: remoteMatches, + ownerId: owner, + state: AssetState.local, + limit: limit, ); final List deleteCandidates = [], originals = []; @@ -50,7 +57,7 @@ class BackupVerificationService { localMatches, compare: (a, b) => a.fileName.compareTo(b.fileName), both: (a, b) async { - a.exifInfo = await _db.exifInfos.get(a.id); + a.exifInfo = await _exifInfoRepository.get(a.id); deleteCandidates.add(a); originals.add(b); return false; @@ -71,6 +78,7 @@ class BackupVerificationService { auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, + fileMediaRepository: _fileMediaRepository, ), ); final upper = compute( @@ -81,6 +89,7 @@ class BackupVerificationService { auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, + fileMediaRepository: _fileMediaRepository, ), ); toDelete = await lower + await upper; @@ -93,6 +102,7 @@ class BackupVerificationService { auth: Store.get(StoreKey.accessToken), endpoint: Store.get(StoreKey.serverEndpoint), rootIsolateToken: isolateToken, + fileMediaRepository: _fileMediaRepository, ), ); } @@ -106,12 +116,13 @@ class BackupVerificationService { String auth, String endpoint, RootIsolateToken rootIsolateToken, + IFileMediaRepository fileMediaRepository, }) tuple, ) async { assert(tuple.deleteCandidates.length == tuple.originals.length); final List result = []; BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken); - await PhotoManager.setIgnorePermissionCheck(true); + await tuple.fileMediaRepository.enableBackgroundAccess(); final ApiService apiService = ApiService(); apiService.setEndpoint(tuple.endpoint); apiService.setAccessToken(tuple.auth); @@ -186,35 +197,6 @@ class BackupVerificationService { return bytes.buffer.asUint64List(start); } - static Future> _getMatches( - QueryBuilder query, - int ownerId, - List assets, - int limit, - ) => - query - .ownerIdEqualTo(ownerId) - .anyOf( - assets, - (q, Asset a) => q - .fileNameEqualTo(a.fileName) - .and() - .durationInSecondsEqualTo(a.durationInSeconds) - .and() - .fileCreatedAtBetween( - a.fileCreatedAt.subtract(const Duration(hours: 12)), - a.fileCreatedAt.add(const Duration(hours: 12)), - ) - .and() - .not() - .checksumEqualTo(a.checksum), - ) - .sortByFileName() - .thenByFileCreatedAt() - .thenByFileModifiedAt() - .limit(limit) - .findAll(); - static bool _sameExceptTimeZone(DateTime a, DateTime b) { final ms = a.isAfter(b) ? a.millisecondsSinceEpoch - b.millisecondsSinceEpoch @@ -227,6 +209,8 @@ class BackupVerificationService { final backupVerificationServiceProvider = Provider( (ref) => BackupVerificationService( - ref.watch(dbProvider), + ref.watch(fileMediaRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), ), ); diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart new file mode 100644 index 0000000000000..996cbe61f192f --- /dev/null +++ b/mobile/lib/services/download.service.dart @@ -0,0 +1,193 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/download.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; +import 'package:immich_mobile/repositories/file_media.repository.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/download.dart'; + +final downloadServiceProvider = Provider( + (ref) => DownloadService( + ref.watch(fileMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), + ), +); + +class DownloadService { + final IDownloadRepository _downloadRepository; + final IFileMediaRepository _fileMediaRepository; + void Function(TaskStatusUpdate)? onImageDownloadStatus; + void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; + + DownloadService( + this._fileMediaRepository, + this._downloadRepository, + ) { + _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; + _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = + _onLivePhotoDownloadCallback; + _downloadRepository.onTaskProgress = _onTaskProgressCallback; + } + + void _onTaskProgressCallback(TaskProgressUpdate update) { + onTaskProgress?.call(update); + } + + void _onImageDownloadCallback(TaskStatusUpdate update) { + onImageDownloadStatus?.call(update); + } + + void _onVideoDownloadCallback(TaskStatusUpdate update) { + onVideoDownloadStatus?.call(update); + } + + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + + Future saveImage(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final data = await File(filePath).readAsBytes(); + + final Asset? resultAsset = await _fileMediaRepository.saveImage( + data, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveVideo(Task task) async { + final filePath = await task.filePath(); + final title = task.filename; + final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + final file = File(filePath); + + final Asset? resultAsset = await _fileMediaRepository.saveVideo( + file, + title: title, + relativePath: relativePath, + ); + + return resultAsset != null; + } + + Future saveLivePhotos( + Task task, + String livePhotosId, + ) async { + try { + final records = await _downloadRepository.getLiveVideoTasks(); + if (records.length < 2) { + return false; + } + + final imageRecord = records.firstWhere( + (record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.image; + }, + ); + + final videoRecord = records.firstWhere((record) { + final metadata = LivePhotosMetadata.fromJson(record.task.metaData); + return metadata.id == livePhotosId && + metadata.part == LivePhotosPart.video; + }); + + final imageFilePath = await imageRecord.task.filePath(); + final videoFilePath = await videoRecord.task.filePath(); + + final resultAsset = await _fileMediaRepository.saveLivePhoto( + image: File(imageFilePath), + video: File(videoFilePath), + title: task.filename, + ); + + await _downloadRepository.deleteRecordsWithIds([ + imageRecord.task.taskId, + videoRecord.task.taskId, + ]); + + return resultAsset != null; + } catch (error) { + debugPrint("Error saving live photo: $error"); + return false; + } + } + + Future cancelDownload(String id) async { + return await FileDownloader().cancelTaskWithId(id); + } + + Future download(Asset asset) async { + if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.image, + id: asset.remoteId!, + ).toJson(), + ), + ); + + await _downloadRepository.download( + _buildDownloadTask( + asset.livePhotoVideoId!, + asset.fileName.toUpperCase().replaceAll(".HEIC", '.MOV'), + group: downloadGroupLivePhoto, + metadata: LivePhotosMetadata( + part: LivePhotosPart.video, + id: asset.remoteId!, + ).toJson(), + ), + ); + } else { + await _downloadRepository.download( + _buildDownloadTask( + asset.remoteId!, + asset.fileName, + group: asset.isImage ? downloadGroupImage : downloadGroupVideo, + ), + ); + } + } + + DownloadTask _buildDownloadTask( + String id, + String filename, { + String? group, + String? metadata, + }) { + final path = r'/assets/{id}/original'.replaceAll('{id}', id); + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final headers = ApiService.getRequestHeaders(); + + return DownloadTask( + taskId: id, + url: serverEndpoint + path, + headers: headers, + filename: filename, + updates: Updates.statusAndProgress, + group: group ?? '', + metaData: metadata ?? '', + ); + } +} diff --git a/mobile/lib/services/entity.service.dart b/mobile/lib/services/entity.service.dart new file mode 100644 index 0000000000000..8297620bc70e3 --- /dev/null +++ b/mobile/lib/services/entity.service.dart @@ -0,0 +1,52 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; + +class EntityService { + final IAssetRepository _assetRepository; + final IUserRepository _userRepository; + EntityService( + this._assetRepository, + this._userRepository, + ); + + Future fillAlbumWithDatabaseEntities(Album album) async { + final ownerId = album.ownerId; + if (ownerId != null) { + // replace owner with user from database + album.owner.value = await _userRepository.get(ownerId); + } + final thumbnailAssetId = + album.remoteThumbnailAssetId ?? album.thumbnail.value?.remoteId; + if (thumbnailAssetId != null) { + // set thumbnail with asset from database + album.thumbnail.value = + await _assetRepository.getByRemoteId(thumbnailAssetId); + } + if (album.remoteUsers.isNotEmpty) { + // replace all users with users from database + final users = await _userRepository + .getByIds(album.remoteUsers.map((user) => user.id).toList()); + album.sharedUsers.clear(); + album.sharedUsers.addAll(users); + } + if (album.remoteAssets.isNotEmpty) { + // replace all assets with assets from database + final assets = await _assetRepository + .getAllByRemoteId(album.remoteAssets.map((asset) => asset.remoteId!)); + album.assets.clear(); + album.assets.addAll(assets); + } + return album; + } +} + +final entityServiceProvider = Provider( + (ref) => EntityService( + ref.watch(assetRepositoryProvider), + ref.watch(userRepositoryProvider), + ), +); diff --git a/mobile/lib/services/hash.service.dart b/mobile/lib/services/hash.service.dart index ffc81a3445acf..bb19340d2ff35 100644 --- a/mobile/lib/services/hash.service.dart +++ b/mobile/lib/services/hash.service.dart @@ -2,70 +2,92 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/device_asset.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:photo_manager/photo_manager.dart'; class HashService { - HashService(this._db, this._backgroundService); - final Isar _db; + HashService( + this._assetRepository, + this._backgroundService, + this._albumMediaRepository, + ); + final IAssetRepository _assetRepository; final BackgroundService _backgroundService; + final IAlbumMediaRepository _albumMediaRepository; final _log = Logger('HashService'); /// Returns all assets that were successfully hashed Future> getHashedAssets( - AssetPathEntity album, { + Album album, { int start = 0, int end = 0x7fffffffffffffff, + DateTime? modifiedFrom, + DateTime? modifiedUntil, Set? excludedAssets, }) async { - final entities = await album.getAssetListRange(start: start, end: end); + final entities = await _albumMediaRepository.getAssets( + album.localId!, + start: start, + end: end, + modifiedFrom: modifiedFrom, + modifiedUntil: modifiedUntil, + ); final filtered = excludedAssets == null ? entities - : entities.where((e) => !excludedAssets.contains(e.id)).toList(); + : entities.where((e) => !excludedAssets.contains(e.localId!)).toList(); return _hashAssets(filtered); } - /// Converts a list of [AssetEntity]s to [Asset]s including only those + /// Processes a list of local [Asset]s, storing their hash and returning only those /// that were successfully hashed. Hashes are looked up in a DB table /// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing /// entries are newly hashed and added to the DB table. - Future> _hashAssets(List assetEntities) async { + Future> _hashAssets(List assets) async { const int batchFileCount = 128; const int batchDataSize = 1024 * 1024 * 1024; // 1GB - final ids = assetEntities - .map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id) + final ids = assets + .map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!) .toList(); - final List hashes = await _lookupHashes(ids); + final List hashes = + await _assetRepository.getDeviceAssetsById(ids); final List toAdd = []; final List toHash = []; int bytes = 0; - for (int i = 0; i < assetEntities.length; i++) { + for (int i = 0; i < assets.length; i++) { if (hashes[i] != null) { continue; } - final file = await assetEntities[i].originFile; - if (file == null) { - final fileName = await assetEntities[i].titleAsync.catchError((error) { - _log.warning( - "Failed to get title for asset ${assetEntities[i].id}", - ); - return ""; - }); + File? file; + + try { + file = await assets[i].local!.originFile; + } catch (error, stackTrace) { + _log.warning( + "Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping", + error, + stackTrace, + ); + } + + if (file == null) { + final fileName = assets[i].fileName; _log.warning( - "Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping", + "Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping", ); continue; } @@ -86,15 +108,9 @@ class HashService { if (toHash.isNotEmpty) { await _processBatch(toHash, toAdd); } - return _mapAllHashedAssets(assetEntities, hashes); + return _getHashedAssets(assets, hashes); } - /// Lookup hashes of assets by their local ID - Future> _lookupHashes(List ids) => - Platform.isAndroid - ? _db.androidDeviceAssets.getAll(ids.cast()) - : _db.iOSDeviceAssets.getAllById(ids.cast()); - /// Processes a batch of files and saves any successfully hashed /// values to the DB table. Future _processBatch( @@ -114,11 +130,9 @@ class HashService { final validHashes = anyNull ? toAdd.where((e) => e.hash.length == 20).toList(growable: false) : toAdd; - await _db.writeTxn( - () => Platform.isAndroid - ? _db.androidDeviceAssets.putAll(validHashes.cast()) - : _db.iOSDeviceAssets.putAll(validHashes.cast()), - ); + + await _assetRepository + .transaction(() => _assetRepository.upsertDeviceAssets(validHashes)); _log.fine("Hashed ${validHashes.length}/${toHash.length} assets"); } @@ -133,15 +147,16 @@ class HashService { return hashes; } - /// Converts [AssetEntity]s that were successfully hashed to [Asset]s - List _mapAllHashedAssets( - List assets, + /// Returns all successfully hashed [Asset]s with their hash value set + List _getHashedAssets( + List assets, List hashes, ) { final List result = []; for (int i = 0; i < assets.length; i++) { if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) { - result.add(Asset.local(assets[i], hashes[i]!.hash)); + assets[i].byteHash = hashes[i]!.hash; + result.add(assets[i]); } } return result; @@ -150,7 +165,8 @@ class HashService { final hashServiceProvider = Provider( (ref) => HashService( - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ref.watch(backgroundServiceProvider), + ref.watch(albumMediaRepositoryProvider), ), ); diff --git a/mobile/lib/services/image_viewer.service.dart b/mobile/lib/services/image_viewer.service.dart deleted file mode 100644 index 9bcaba1d26b95..0000000000000 --- a/mobile/lib/services/image_viewer.service.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/response_extensions.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:logging/logging.dart'; - -import 'package:photo_manager/photo_manager.dart'; -import 'package:path_provider/path_provider.dart'; - -final imageViewerServiceProvider = - Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider))); - -class ImageViewerService { - final ApiService _apiService; - final Logger _log = Logger("ImageViewerService"); - - ImageViewerService(this._apiService); - - Future downloadAsset(Asset asset) async { - File? imageFile; - File? videoFile; - try { - // Download LivePhotos image and motion part - if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - var imageResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.remoteId!, - ); - - var motionResponse = - await _apiService.assetsApi.downloadAssetWithHttpInfo( - asset.livePhotoVideoId!, - ); - - if (imageResponse.statusCode != 200 || - motionResponse.statusCode != 200) { - final failedResponse = - imageResponse.statusCode != 200 ? imageResponse : motionResponse; - _log.severe( - "Motion asset download failed", - failedResponse.toLoggerString(), - ); - return false; - } - - AssetEntity? entity; - - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/livephoto.mov').create(); - imageFile = await File('${tempDir.path}/livephoto.heic').create(); - videoFile.writeAsBytesSync(motionResponse.bodyBytes); - imageFile.writeAsBytesSync(imageResponse.bodyBytes); - - entity = await PhotoManager.editor.darwin.saveLivePhoto( - imageFile: imageFile, - videoFile: videoFile, - title: asset.fileName, - ); - - if (entity == null) { - _log.warning( - "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file", - ); - - entity = await PhotoManager.editor.saveImage( - imageResponse.bodyBytes, - title: asset.fileName, - ); - } - - return entity != null; - } else { - var res = await _apiService.assetsApi - .downloadAssetWithHttpInfo(asset.remoteId!); - - if (res.statusCode != 200) { - _log.severe("Asset download failed", res.toLoggerString()); - return false; - } - - final AssetEntity? entity; - final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; - - if (asset.isImage) { - entity = await PhotoManager.editor.saveImage( - res.bodyBytes, - title: asset.fileName, - relativePath: relativePath, - ); - } else { - final tempDir = await getTemporaryDirectory(); - videoFile = await File('${tempDir.path}/${asset.fileName}').create(); - videoFile.writeAsBytesSync(res.bodyBytes); - entity = await PhotoManager.editor.saveVideo( - videoFile, - title: asset.fileName, - relativePath: relativePath, - ); - } - return entity != null; - } - } catch (error, stack) { - _log.severe("Error saving downloaded asset", error, stack); - return false; - } finally { - // Clear temp files - imageFile?.delete(); - videoFile?.delete(); - } - } -} diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index ea07f7c019ea8..b95899df678ec 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,18 +1,17 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final memoryServiceProvider = StateProvider((ref) { return MemoryService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ); }); @@ -20,9 +19,9 @@ class MemoryService { final log = Logger("MemoryService"); final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; - MemoryService(this._apiService, this._db); + MemoryService(this._apiService, this._assetRepository); Future?> getMemoryLane() async { try { @@ -39,7 +38,7 @@ class MemoryService { List memories = []; for (final MemoryLaneResponseDto(:yearsAgo, :assets) in data) { final dbAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); + await _assetRepository.getAllByRemoteId(assets.map((e) => e.id)); if (dbAssets.isNotEmpty) { final String title = yearsAgo <= 1 ? 'memories_year_ago'.tr() diff --git a/mobile/lib/services/partner.service.dart b/mobile/lib/services/partner.service.dart index 8cd2fe424f190..67d7f4e1d1f56 100644 --- a/mobile/lib/services/partner.service.dart +++ b/mobile/lib/services/partner.service.dart @@ -1,43 +1,33 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final partnerServiceProvider = Provider( (ref) => PartnerService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userRepositoryProvider), ), ); class PartnerService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserRepository _userRepository; final Logger _log = Logger("PartnerService"); - PartnerService(this._apiService, this._db); - - Future?> getPartners(PartnerDirection direction) async { - try { - final userDtos = await _apiService.partnersApi.getPartners(direction); - if (userDtos != null) { - return userDtos.map((u) => User.fromPartnerDto(u)).toList(); - } - } catch (e) { - _log.warning("Failed to get partners for direction $direction", e); - } - return null; - } + PartnerService( + this._partnerApiRepository, + this._userRepository, + ); Future removePartner(User partner) async { try { - await _apiService.partnersApi.removePartner(partner.id); + await _partnerApiRepository.delete(partner.id); partner.isPartnerSharedBy = false; - await _db.writeTxn(() => _db.users.put(partner)); + await _userRepository.update(partner); } catch (e) { _log.warning("Failed to remove partner ${partner.id}", e); return false; @@ -47,12 +37,10 @@ class PartnerService { Future addPartner(User partner) async { try { - final dto = await _apiService.partnersApi.createPartner(partner.id); - if (dto != null) { - partner.isPartnerSharedBy = true; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + await _partnerApiRepository.create(partner.id); + partner.isPartnerSharedBy = true; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to add partner ${partner.id}", e); } @@ -61,13 +49,13 @@ class PartnerService { Future updatePartner(User partner, {required bool inTimeline}) async { try { - final dto = await _apiService.partnersApi - .updatePartner(partner.id, UpdatePartnerDto(inTimeline: inTimeline)); - if (dto != null) { - partner.inTimeline = dto.inTimeline ?? partner.inTimeline; - await _db.writeTxn(() => _db.users.put(partner)); - return true; - } + final dto = await _partnerApiRepository.update( + partner.id, + inTimeline: inTimeline, + ); + partner.inTimeline = dto.inTimeline; + await _userRepository.update(partner); + return true; } catch (e) { _log.warning("Failed to update partner ${partner.id}", e); } diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index ddb61f5e48a40..5b325acdc591b 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -1,29 +1,37 @@ import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_api.interface.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/person_api.repository.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'person.service.g.dart'; @riverpod -PersonService personService(PersonServiceRef ref) => - PersonService(ref.read(apiServiceProvider), ref.read(dbProvider)); +PersonService personService(PersonServiceRef ref) => PersonService( + ref.watch(personApiRepositoryProvider), + ref.watch(assetApiRepositoryProvider), + ref.read(assetRepositoryProvider), + ); class PersonService { final Logger _log = Logger("PersonService"); - final ApiService _apiService; - final Isar _db; + final IPersonApiRepository _personApiRepository; + final IAssetApiRepository _assetApiRepository; + final IAssetRepository _assetRepository; - PersonService(this._apiService, this._db); + PersonService( + this._personApiRepository, + this._assetApiRepository, + this._assetRepository, + ); - Future> getAllPeople() async { + Future> getAllPeople() async { try { - final peopleResponseDto = await _apiService.peopleApi.getAllPeople(); - return peopleResponseDto?.people ?? []; + return await _personApiRepository.getAll(); } catch (error, stack) { _log.severe("Error while fetching curated people", error, stack); return []; @@ -31,50 +39,19 @@ class PersonService { } Future> getPersonAssets(String id) async { - List result = []; - var hasNext = true; - var currentPage = 1; - try { - while (hasNext) { - final response = await _apiService.searchApi.searchMetadata( - MetadataSearchDto( - personIds: [id], - page: currentPage, - size: 1000, - ), - ); - - if (response == null) { - break; - } - - if (response.assets.nextPage == null) { - hasNext = false; - } - - final assets = response.assets.items; - final mapAssets = - await _db.assets.getAllByRemoteId(assets.map((e) => e.id)); - result.addAll(mapAssets); - - currentPage++; - } + final assets = await _assetApiRepository.search(personIds: [id]); + return await _assetRepository + .getAllByRemoteId(assets.map((a) => a.remoteId!)); } catch (error, stack) { _log.severe("Error while fetching person assets", error, stack); } - - return result; + return []; } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { - return await _apiService.peopleApi.updatePerson( - id, - PersonUpdateDto( - name: name, - ), - ); + return await _personApiRepository.update(id, name: name); } catch (error, stack) { _log.severe("Error while updating person name", error, stack); } diff --git a/mobile/lib/services/person.service.g.dart b/mobile/lib/services/person.service.g.dart index 01a5ed8f30943..9a24069fbffa5 100644 --- a/mobile/lib/services/person.service.g.dart +++ b/mobile/lib/services/person.service.g.dart @@ -6,7 +6,7 @@ part of 'person.service.dart'; // RiverpodGenerator // ************************************************************************** -String _$personServiceHash() => r'54e6df4b8eea744f6de009f8315c9fe6230f6798'; +String _$personServiceHash() => r'32f28cb5a3de0553c17447e33a0efde7409a43ed'; /// See also [personService]. @ProviderFor(personService) diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index cf3905e5ca240..336fe450108d3 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,27 +1,27 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final searchServiceProvider = Provider( (ref) => SearchService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); class SearchService { final ApiService _apiService; - final Isar _db; + final IAssetRepository _assetRepository; final _log = Logger("SearchService"); - SearchService(this._apiService, this._db); + SearchService(this._apiService, this._assetRepository); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -103,7 +103,7 @@ class SearchService { return null; } - return _db.assets + return _assetRepository .getAllByRemoteId(response.assets.items.map((e) => e.id)); } catch (error, stackTrace) { _log.severe("Failed to search for assets", error, stackTrace); diff --git a/mobile/lib/services/stack.service.dart b/mobile/lib/services/stack.service.dart index 75074101c2ff8..1ca56ff2791cd 100644 --- a/mobile/lib/services/stack.service.dart +++ b/mobile/lib/services/stack.service.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; class StackService { - StackService(this._api, this._db); + StackService(this._api, this._assetRepository); final ApiService _api; - final Isar _db; + final IAssetRepository _assetRepository; Future getStack(String stackId) async { try { @@ -61,10 +61,8 @@ class StackService { removeAssets.add(asset); } - - _db.writeTxn(() async { - await _db.assets.putAll(removeAssets); - }); + await _assetRepository + .transaction(() => _assetRepository.updateAll(removeAssets)); } catch (error) { debugPrint("Error while deleting stack: $error"); } @@ -74,6 +72,6 @@ class StackService { final stackServiceProvider = Provider( (ref) => StackService( ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(assetRepositoryProvider), ), ); diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 8ec56e925f7f8..e7a192e7834ea 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -5,31 +5,67 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart'; -import 'package:immich_mobile/entities/exif_info.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/album_api.interface.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/repositories/album.repository.dart'; +import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/etag.repository.dart'; +import 'package:immich_mobile/repositories/exif_info.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; -import 'package:photo_manager/photo_manager.dart'; final syncServiceProvider = Provider( - (ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)), + (ref) => SyncService( + ref.watch(hashServiceProvider), + ref.watch(entityServiceProvider), + ref.watch(albumMediaRepositoryProvider), + ref.watch(albumApiRepositoryProvider), + ref.watch(albumRepositoryProvider), + ref.watch(assetRepositoryProvider), + ref.watch(exifInfoRepositoryProvider), + ref.watch(userRepositoryProvider), + ref.watch(etagRepositoryProvider), + ), ); class SyncService { - final Isar _db; final HashService _hashService; + final EntityService _entityService; + final IAlbumMediaRepository _albumMediaRepository; + final IAlbumApiRepository _albumApiRepository; + final IAlbumRepository _albumRepository; + final IAssetRepository _assetRepository; + final IExifInfoRepository _exifInfoRepository; + final IUserRepository _userRepository; + final IETagRepository _eTagRepository; final AsyncMutex _lock = AsyncMutex(); final Logger _log = Logger('SyncService'); - SyncService(this._db, this._hashService); + SyncService( + this._hashService, + this._entityService, + this._albumMediaRepository, + this._albumApiRepository, + this._albumRepository, + this._assetRepository, + this._exifInfoRepository, + this._userRepository, + this._eTagRepository, + ); // public methods: @@ -59,16 +95,15 @@ class SyncService { /// Syncs remote albums to the database /// returns `true` if there were any changes Future syncRemoteAlbumsToDb( - List remote, { + List remote, { required bool isShared, - required FutureOr Function(AlbumResponseDto) loadDetails, }) => - _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails)); + _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared)); /// Syncs all device albums and their assets to the database /// Returns `true` if there were any changes Future syncLocalAlbumAssetsToDb( - List onDevice, [ + List onDevice, [ Set? excludedAssets, ]) => _lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets)); @@ -102,8 +137,7 @@ class SyncService { /// Returns `true`if there were any changes Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); - final dbUsers = await _db.users.where().sortById().findAll(); - assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); + final dbUsers = await _userRepository.getAll(sortBy: UserSort.id); final List toDelete = []; final List toUpsert = []; final changes = diffSortedListsSync( @@ -124,9 +158,9 @@ class SyncService { onlySecond: (User b) => toDelete.add(b.isarId), ); if (changes) { - await _db.writeTxn(() async { - await _db.users.deleteAll(toDelete); - await _db.users.putAll(toUpsert); + await _userRepository.transaction(() async { + await _userRepository.deleteById(toDelete); + await _userRepository.upsertAll(toUpsert); }); } return changes; @@ -135,15 +169,15 @@ class SyncService { /// Syncs a new asset to the db. Returns `true` if successful Future _syncNewAssetToDb(Asset a) async { final Asset? inDb = - await _db.assets.getByOwnerIdChecksum(a.ownerId, a.checksum); + await _assetRepository.getByOwnerIdChecksum(a.ownerId, a.checksum); if (inDb != null) { // unify local/remote assets by replacing the // local-only asset in the DB with a local&remote asset a = inDb.updatedCopy(a); } try { - await _db.writeTxn(() => a.put(_db)); - } on IsarError catch (e) { + await _assetRepository.update(a); + } catch (e) { _log.severe("Failed to put new asset into db", e); return false; } @@ -158,9 +192,9 @@ class SyncService { DateTime since, ) getChangedAssets, ) async { - final currentUser = Store.get(StoreKey.currentUser); + final currentUser = await _userRepository.me(); final DateTime? since = - _db.eTags.getSync(currentUser.isarId)?.time?.toUtc(); + (await _eTagRepository.get(currentUser.isarId))?.time?.toUtc(); if (since == null) return null; final DateTime now = DateTime.now(); final (toUpsert, toDelete) = await getChangedAssets(users, since); @@ -181,7 +215,7 @@ class SyncService { return true; } return false; - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } return null; @@ -189,23 +223,21 @@ class SyncService { /// Deletes remote-only assets, updates merged assets to be local-only Future handleRemoteAssetRemoval(List idsToDelete) { - return _db.writeTxn(() async { - final idsToRemove = await _db.assets - .remote(idsToDelete) - .filter() - .localIdIsNull() - .idProperty() - .findAll(); - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - final onlyLocal = await _db.assets.remote(idsToDelete).findAll(); - if (onlyLocal.isNotEmpty) { - for (final Asset a in onlyLocal) { - a.remoteId = null; - a.isTrashed = false; - } - await _db.assets.putAll(onlyLocal); + return _assetRepository.transaction(() async { + await _assetRepository.deleteAllByRemoteId( + idsToDelete, + state: AssetState.remote, + ); + final merged = await _assetRepository.getAllByRemoteId( + idsToDelete, + state: AssetState.merged, + ); + if (merged.isEmpty) return; + for (final Asset asset in merged) { + asset.remoteId = null; + asset.isTrashed = false; } + await _assetRepository.updateAll(merged); }); } @@ -220,12 +252,7 @@ class SyncService { return false; } await _syncUsersFromServer(serverUsers); - final List users = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .or() - .isarIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .findAll(); + final List users = await _userRepository.getAllAccessible(); bool changes = false; for (User u in users) { changes |= await _syncRemoteAssetsForUser(u, loadAssets); @@ -242,11 +269,10 @@ class SyncService { if (remote == null) { return false; } - final List inDb = await _db.assets - .where() - .ownerIdEqualToAnyChecksum(user.isarId) - .sortByChecksum() - .findAll(); + final List inDb = await _assetRepository.getAll( + ownerId: user.isarId, + sortBy: AssetSort.checksum, + ); assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); remote.sort(Asset.compareByChecksum); @@ -261,9 +287,9 @@ class SyncService { } final idsToDelete = toRemove.map((e) => e.id).toList(); try { - await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); + await _assetRepository.deleteById(idsToDelete); await upsertAssetsWithExif(toAdd + toUpdate); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote assets to db", e); } await _updateUserAssetsETag([user], now); @@ -272,55 +298,47 @@ class SyncService { Future _updateUserAssetsETag(List users, DateTime time) { final etags = users.map((u) => ETag(id: u.id, time: time)).toList(); - return _db.writeTxn(() => _db.eTags.putAll(etags)); + return _eTagRepository.upsertAll(etags); } Future _clearUserAssetsETag(List users) { final ids = users.map((u) => u.id).toList(); - return _db.writeTxn(() => _db.eTags.deleteAllById(ids)); + return _eTagRepository.deleteByIds(ids); } /// Syncs remote albums to the database /// returns `true` if there were any changes Future _syncRemoteAlbumsToDb( - List remote, + List remoteAlbums, bool isShared, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - remote.sortBy((e) => e.id); - - final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); - final QueryBuilder query; - if (isShared) { - query = baseQuery.sharedEqualTo(true); - } else { - final User me = Store.get(StoreKey.currentUser); - query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId)); - } - final List dbAlbums = await query.sortByRemoteId().findAll(); - assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!"); - + remoteAlbums.sortBy((e) => e.remoteId!); + + final User me = await _userRepository.me(); + final List dbAlbums = await _albumRepository.getAll( + remote: true, + shared: isShared ? true : null, + ownerId: isShared ? null : me.isarId, + sortBy: AlbumSort.remoteId, + ); final List toDelete = []; final List existing = []; final bool changes = await diffSortedLists( - remote, + remoteAlbums, dbAlbums, - compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!), - both: (AlbumResponseDto a, Album b) => - _syncRemoteAlbum(a, b, toDelete, existing, loadDetails), - onlyFirst: (AlbumResponseDto a) => - _addAlbumFromServer(a, existing, loadDetails), - onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete), + compare: (remoteAlbum, dbAlbum) => + remoteAlbum.remoteId!.compareTo(dbAlbum.remoteId!), + both: (remoteAlbum, dbAlbum) => + _syncRemoteAlbum(remoteAlbum, dbAlbum, toDelete, existing), + onlyFirst: (remoteAlbum) => _addAlbumFromServer(remoteAlbum, existing), + onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete), ); if (isShared && toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(idsToRemove); - await _db.exifInfos.deleteAll(idsToRemove); - }); + await _assetRepository.deleteById(idsToRemove); } } else { assert(toDelete.isEmpty); @@ -332,26 +350,25 @@ class SyncService { /// syncing changes from local back to server) /// accumulates Future _syncRemoteAlbum( - AlbumResponseDto dto, + Album dto, Album album, List deleteCandidates, List existing, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - if (!_hasAlbumResponseDtoChanged(dto, album)) { + if (!_hasRemoteAlbumChanged(dto, album)) { return false; } // loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp, // i.e. it will always be null. Save it here. final originalDto = dto; - dto = await loadDetails(dto); - if (dto.assetCount != dto.assets.length) { - return false; - } - final assetsInDb = - await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); + dto = await _albumApiRepository.get(dto.remoteId!); + + final assetsInDb = await _assetRepository.getByAlbum( + album, + sortBy: AssetSort.ownerIdChecksum, + ); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); - final List assetsOnRemote = dto.getAssets(); + final List assetsOnRemote = dto.remoteAssets.toList(); assetsOnRemote.sort(Asset.compareByOwnerChecksum); final (toAdd, toUpdate, toUnlink) = _diffAssets( assetsOnRemote, @@ -362,15 +379,16 @@ class SyncService { // update shared users final List sharedUsers = album.sharedUsers.toList(growable: false); sharedUsers.sort((a, b) => a.id.compareTo(b.id)); - dto.albumUsers.sort((a, b) => a.user.id.compareTo(b.user.id)); + final List users = dto.remoteUsers.toList() + ..sort((a, b) => a.id.compareTo(b.id)); final List userIdsToAdd = []; final List usersToUnlink = []; diffSortedListsSync( - dto.albumUsers, + users, sharedUsers, - compare: (AlbumUserResponseDto a, User b) => a.user.id.compareTo(b.id), + compare: (User a, User b) => a.id.compareTo(b.id), both: (a, b) => false, - onlyFirst: (AlbumUserResponseDto a) => userIdsToAdd.add(a.user.id), + onlyFirst: (User a) => userIdsToAdd.add(a.id), onlySecond: (User a) => usersToUnlink.add(a), ); @@ -378,43 +396,44 @@ class SyncService { final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); await upsertAssetsWithExif(updated); final assetsToLink = existingInDb + updated; - final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); + final usersToLink = await _userRepository.getByIds(userIdsToAdd); - album.name = dto.albumName; + album.name = dto.name; album.shared = dto.shared; album.createdAt = dto.createdAt; - album.modifiedAt = dto.updatedAt; + album.modifiedAt = dto.modifiedAt; album.startDate = dto.startDate; album.endDate = dto.endDate; album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; - album.activityEnabled = dto.isActivityEnabled; - if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) { - album.thumbnail.value = await _db.assets - .where() - .remoteIdEqualTo(dto.albumThumbnailAssetId) - .findFirst(); + album.activityEnabled = dto.activityEnabled; + final remoteThumbnailAssetId = dto.remoteThumbnailAssetId; + if (remoteThumbnailAssetId != null && + album.thumbnail.value?.remoteId != remoteThumbnailAssetId) { + album.thumbnail.value = + await _assetRepository.getByRemoteId(remoteThumbnailAssetId); } // write & commit all changes to DB try { - await _db.writeTxn(() async { - await _db.assets.putAll(toUpdate); - await album.thumbnail.save(); - await album.sharedUsers - .update(link: usersToLink, unlink: usersToUnlink); - await album.assets.update(link: assetsToLink, unlink: toUnlink.cast()); - await _db.albums.put(album); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(toUpdate); + await _albumRepository.addUsers(album, usersToLink); + await _albumRepository.removeUsers(album, usersToUnlink); + await _albumRepository.addAssets(album, assetsToLink); + await _albumRepository.removeAssets(album, toUnlink); + await _albumRepository.recalculateMetadata(album); + await _albumRepository.update(album); }); _log.info("Synced changes of remote album ${album.name} to DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to sync remote album to database", e); } if (album.shared || dto.shared) { - final userId = Store.get(StoreKey.currentUser).isarId; + final userId = (await _userRepository.me()).isarId; final foreign = - await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); + await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); existing.addAll(foreign); // delete assets in DB unless they belong to this user or part of some other shared album @@ -428,27 +447,26 @@ class SyncService { /// (shared) assets to the database beforehand /// accumulates assets already existing in the database Future _addAlbumFromServer( - AlbumResponseDto dto, + Album album, List existing, - FutureOr Function(AlbumResponseDto) loadDetails, ) async { - if (dto.assetCount != dto.assets.length) { - dto = await loadDetails(dto); + if (album.remoteAssetCount != album.remoteAssets.length) { + album = await _albumApiRepository.get(album.remoteId!); } - if (dto.assetCount == dto.assets.length) { + if (album.remoteAssetCount == album.remoteAssets.length) { // in case an album contains assets not yet present in local DB: // put missing album assets into local DB final (existingInDb, updated) = - await _linkWithExistingFromDb(dto.getAssets()); + await _linkWithExistingFromDb(album.remoteAssets.toList()); existing.addAll(existingInDb); await upsertAssetsWithExif(updated); - final Album a = await Album.remote(dto); - await _db.writeTxn(() => _db.albums.store(a)); + await _entityService.fillAlbumWithDatabaseEntities(album); + await _albumRepository.create(album); } else { _log.warning( - "Failed to add album from server: assetCount ${dto.assetCount} != " - "asset array length ${dto.assets.length} for album ${dto.albumName}"); + "Failed to add album from server: assetCount ${album.remoteAssetCount} != " + "asset array length ${album.remoteAssets.length} for album ${album.name}"); } } @@ -462,27 +480,18 @@ class SyncService { _log.info("Removing local album $album from DB"); // delete assets in DB unless they are remote or part of some other album deleteCandidates.addAll( - await album.assets.filter().remoteIdIsNull().findAll(), + await _assetRepository.getByAlbum(album, state: AssetState.local), ); } else if (album.shared) { - final User user = Store.get(StoreKey.currentUser); // delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner - final userIds = await _db.users - .filter() - .isPartnerSharedWithEqualTo(true) - .isarIdProperty() - .findAll(); - userIds.add(user.isarId); - final orphanedAssets = await album.assets - .filter() - .not() - .anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id)) - .findAll(); + final userIds = + (await _userRepository.getAllAccessible()).map((user) => user.isarId); + final orphanedAssets = + await _assetRepository.getByAlbum(album, notOwnedBy: userIds); deleteCandidates.addAll(orphanedAssets); } try { - final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id)); - assert(ok); + await _albumRepository.delete(album.id); _log.info("Removed local album $album from DB"); } catch (e) { _log.severe("Failed to remove local album $album from DB", e); @@ -492,28 +501,26 @@ class SyncService { /// Syncs all device albums and their assets to the database /// Returns `true` if there were any changes Future _syncLocalAlbumAssetsToDb( - List onDevice, [ + List onDevice, [ Set? excludedAssets, ]) async { - onDevice.sort((a, b) => a.id.compareTo(b.id)); + onDevice.sort((a, b) => a.localId!.compareTo(b.localId!)); final inDb = - await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll(); + await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId); final List deleteCandidates = []; final List existing = []; - assert(inDb.isSorted((a, b) => a.localId!.compareTo(b.localId!)), "sort!"); final bool anyChanges = await diffSortedLists( onDevice, inDb, - compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!), - both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice( - ape, - album, + compare: (Album a, Album b) => a.localId!.compareTo(b.localId!), + both: (Album a, Album b) => _syncAlbumInDbAndOnDevice( + a, + b, deleteCandidates, existing, excludedAssets, ), - onlyFirst: (AssetPathEntity ape) => - _addAlbumFromDevice(ape, existing, excludedAssets), + onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets), onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates), ); _log.fine( @@ -525,10 +532,9 @@ class SyncService { "${toDelete.length} assets to delete, ${toUpdate.length} to update", ); if (toDelete.isNotEmpty || toUpdate.isNotEmpty) { - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.exifInfos.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); }); _log.info( "Removed ${toDelete.length} and updated ${toUpdate.length} local assets from DB", @@ -541,58 +547,64 @@ class SyncService { /// returns `true` if there were any changes /// Accumulates asset candidates to delete and those already existing in DB Future _syncAlbumInDbAndOnDevice( - AssetPathEntity ape, - Album album, + Album deviceAlbum, + Album dbAlbum, List deleteCandidates, List existing, [ Set? excludedAssets, bool forceRefresh = false, ]) async { - if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) { - _log.fine("Local album ${ape.name} has not changed. Skipping sync."); + if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) { + _log.fine( + "Local album ${deviceAlbum.name} has not changed. Skipping sync.", + ); return false; } if (!forceRefresh && excludedAssets == null && - await _syncDeviceAlbumFast(ape, album)) { + await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) { return true; } - // general case, e.g. some assets have been deleted or there are excluded albums on iOS - final inDb = await album.assets - .filter() - .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) - .sortByChecksum() - .findAll(); + final inDb = await _assetRepository.getByAlbum( + dbAlbum, + ownerId: (await _userRepository.me()).isarId, + sortBy: AssetSort.checksum, + ); + assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!"); - final int assetCountOnDevice = await ape.assetCountAsync; - final List onDevice = - await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); + final int assetCountOnDevice = + await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); + final List onDevice = await _hashService.getHashedAssets( + deviceAlbum, + excludedAssets: excludedAssets, + ); _removeDuplicates(onDevice); // _removeDuplicates sorts `onDevice` by checksum final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb); if (toAdd.isEmpty && toUpdate.isEmpty && toDelete.isEmpty && - album.name == ape.name && - ape.lastModified != null && - album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) { + dbAlbum.name == deviceAlbum.name && + dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) { // changes only affeted excluded albums _log.fine( - "Only excluded assets in local album ${ape.name} changed. Stopping sync.", + "Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.", ); if (assetCountOnDevice != - _db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) { - await _db.writeTxn( - () => _db.eTags.put( - ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice), + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount) { + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, ), - ); + ]); } return false; } _log.fine( - "Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete", + "Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete", ); final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd); _log.fine( @@ -600,28 +612,29 @@ class SyncService { ); deleteCandidates.addAll(toDelete); existing.addAll(existingInDb); - album.name = ape.name; - album.modifiedAt = ape.lastModified ?? DateTime.now(); - if (album.thumbnail.value != null && - toDelete.contains(album.thumbnail.value)) { - album.thumbnail.value = null; + dbAlbum.name = deviceAlbum.name; + dbAlbum.modifiedAt = deviceAlbum.modifiedAt; + if (dbAlbum.thumbnail.value != null && + toDelete.contains(dbAlbum.thumbnail.value)) { + dbAlbum.thumbnail.value = null; } try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await _db.assets.putAll(toUpdate); - await album.assets - .update(link: existingInDb + updated, unlink: toDelete); - await _db.albums.put(album); - album.thumbnail.value ??= await album.assets.filter().findFirst(); - await album.thumbnail.save(); - await _db.eTags.put( - ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice), - ); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated + toUpdate); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.removeAssets(dbAlbum, toDelete); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll([ + ETag( + id: deviceAlbum.eTagKeyAssetCount, + assetCount: assetCountOnDevice, + ), + ]); }); - _log.info("Synced changes of local album ${ape.name} to DB"); - } on IsarError catch (e) { - _log.severe("Failed to update synced album ${ape.name} in DB", e); + _log.info("Synced changes of local album ${deviceAlbum.name} to DB"); + } catch (e) { + _log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e); } return true; @@ -629,45 +642,47 @@ class SyncService { /// fast path for common case: only new assets were added to device album /// returns `true` if successfull, else `false` - Future _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async { - if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) { + Future _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async { + if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) { return false; } - final int totalOnDevice = await ape.assetCountAsync; + final int totalOnDevice = + await _albumMediaRepository.getAssetCount(deviceAlbum.localId!); final int lastKnownTotal = - (await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0; - final AssetPathEntity? modified = totalOnDevice > lastKnownTotal - ? await ape.fetchPathProperties( - filterOptionGroup: FilterOptionGroup( - updateTimeCond: DateTimeCond( - min: album.modifiedAt.add(const Duration(seconds: 1)), - max: ape.lastModified ?? DateTime.now(), - ), - ), - ) - : null; - if (modified == null) { + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount ?? + 0; + if (totalOnDevice <= lastKnownTotal) { return false; } - final List newAssets = await _hashService.getHashedAssets(modified); + final List newAssets = await _hashService.getHashedAssets( + deviceAlbum, + modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)), + modifiedUntil: deviceAlbum.modifiedAt, + ); if (totalOnDevice != lastKnownTotal + newAssets.length) { return false; } - album.modifiedAt = ape.lastModified ?? DateTime.now(); + dbAlbum.modifiedAt = deviceAlbum.modifiedAt; _removeDuplicates(newAssets); final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets); try { - await _db.writeTxn(() async { - await _db.assets.putAll(updated); - await album.assets.update(link: existingInDb + updated); - await _db.albums.put(album); - await _db.eTags - .put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice)); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(updated); + await _albumRepository.addAssets(dbAlbum, existingInDb + updated); + await _albumRepository.recalculateMetadata(dbAlbum); + await _albumRepository.update(dbAlbum); + await _eTagRepository.upsertAll( + [ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice)], + ); }); - _log.info("Fast synced local album ${ape.name} to DB"); - } on IsarError catch (e) { - _log.severe("Failed to fast sync local album ${ape.name} to DB", e); + _log.info("Fast synced local album ${deviceAlbum.name} to DB"); + } catch (e) { + _log.severe( + "Failed to fast sync local album ${deviceAlbum.name} to DB", + e, + ); return false; } @@ -677,14 +692,15 @@ class SyncService { /// Adds a new album from the device to the database and Accumulates all /// assets already existing in the database to the list of `existing` assets Future _addAlbumFromDevice( - AssetPathEntity ape, + Album album, List existing, [ Set? excludedAssets, ]) async { - _log.info("Syncing a new local album to DB: ${ape.name}"); - final Album a = Album.local(ape); - final assets = - await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets); + _log.info("Syncing a new local album to DB: ${album.name}"); + final assets = await _hashService.getHashedAssets( + album, + excludedAssets: excludedAssets, + ); _removeDuplicates(assets); final (existingInDb, updated) = await _linkWithExistingFromDb(assets); _log.info( @@ -692,15 +708,15 @@ class SyncService { ); await upsertAssetsWithExif(updated); existing.addAll(existingInDb); - a.assets.addAll(existingInDb); - a.assets.addAll(updated); + album.assets.addAll(existingInDb); + album.assets.addAll(updated); final thumb = existingInDb.firstOrNull ?? updated.firstOrNull; - a.thumbnail.value = thumb; + album.thumbnail.value = thumb; try { - await _db.writeTxn(() => _db.albums.store(a)); - _log.info("Added a new local album to DB: ${ape.name}"); - } on IsarError catch (e) { - _log.severe("Failed to add new local album ${ape.name} to DB", e); + await _albumRepository.create(album); + _log.info("Added a new local album to DB: ${album.name}"); + } catch (e) { + _log.severe("Failed to add new local album ${album.name} to DB", e); } } @@ -710,7 +726,7 @@ class SyncService { ) async { if (assets.isEmpty) return ([].cast(), [].cast()); - final List inDb = await _db.assets.getAllByOwnerIdChecksum( + final List inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((a) => a.ownerId).toInt64List(), assets.map((a) => a.checksum).toList(growable: false), ); @@ -724,7 +740,7 @@ class SyncService { } if (b.canUpdate(assets[i])) { final updated = b.updatedCopy(assets[i]); - assert(updated.id != Isar.autoIncrement); + assert(updated.isInDb); toUpsert.add(updated); } else { existing.add(b); @@ -736,24 +752,22 @@ class SyncService { /// Inserts or updates the assets in the database with their ExifInfo (if any) Future upsertAssetsWithExif(List assets) async { - if (assets.isEmpty) { - return; - } - final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList(); + if (assets.isEmpty) return; + final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList(); try { - await _db.writeTxn(() async { - await _db.assets.putAll(assets); + await _assetRepository.transaction(() async { + await _assetRepository.updateAll(assets); for (final Asset added in assets) { added.exifInfo?.id = added.id; } - await _db.exifInfos.putAll(exifInfos); + await _exifInfoRepository.updateAll(exifInfos); }); _log.info("Upserted ${assets.length} assets into the DB"); - } on IsarError catch (e) { + } catch (e) { _log.severe("Failed to upsert ${assets.length} assets into the DB", e); // give details on the errors assets.sort(Asset.compareByOwnerChecksum); - final inDb = await _db.assets.getAllByOwnerIdChecksum( + final inDb = await _assetRepository.getAllByOwnerIdChecksum( assets.map((e) => e.ownerId).toInt64List(), assets.map((e) => e.checksum).toList(growable: false), ); @@ -761,7 +775,7 @@ class SyncService { final Asset a = assets[i]; final Asset? b = inDb[i]; if (b == null) { - if (a.id != Isar.autoIncrement) { + if (!a.isInDb) { _log.warning( "Trying to update an asset that does not exist in DB:\n$a", ); @@ -798,23 +812,26 @@ class SyncService { } /// returns `true` if the albums differ on the surface - Future _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async { - return a.name != b.name || - a.lastModified == null || - !a.lastModified!.isAtSameMomentAs(b.modifiedAt) || - await a.assetCountAsync != - (await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount; + Future _hasAlbumChangeOnDevice( + Album deviceAlbum, + Album dbAlbum, + ) async { + return deviceAlbum.name != dbAlbum.name || + !deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || + await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) != + (await _eTagRepository.getById(deviceAlbum.eTagKeyAssetCount)) + ?.assetCount; } Future _removeAllLocalAlbumsAndAssets() async { try { - final assets = await _db.assets.where().localIdIsNotNull().findAll(); + final assets = await _assetRepository.getAllLocal(); final (toDelete, toUpdate) = _handleAssetRemoval(assets, [], remote: false); - await _db.writeTxn(() async { - await _db.assets.deleteAll(toDelete); - await _db.assets.putAll(toUpdate); - await _db.albums.where().localIdIsNotNull().deleteAll(); + await _assetRepository.transaction(() async { + await _assetRepository.deleteById(toDelete); + await _assetRepository.updateAll(toUpdate); + await _albumRepository.deleteAllLocal(); }); return true; } catch (e) { @@ -900,17 +917,17 @@ class SyncService { } /// returns `true` if the albums differ on the surface -bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) { - return dto.assetCount != a.assetCount || - dto.albumName != a.name || - dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId || - dto.shared != a.shared || - dto.albumUsers.length != a.sharedUsers.length || - !dto.updatedAt.isAtSameMomentAs(a.modifiedAt) || - !isAtSameMomentAs(dto.startDate, a.startDate) || - !isAtSameMomentAs(dto.endDate, a.endDate) || +bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) { + return remoteAlbum.remoteAssetCount != dbAlbum.assetCount || + remoteAlbum.name != dbAlbum.name || + remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId || + remoteAlbum.shared != dbAlbum.shared || + remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length || + !remoteAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) || + !isAtSameMomentAs(remoteAlbum.startDate, dbAlbum.startDate) || + !isAtSameMomentAs(remoteAlbum.endDate, dbAlbum.endDate) || !isAtSameMomentAs( - dto.lastModifiedAssetTimestamp, - a.lastModifiedAssetTimestamp, + remoteAlbum.lastModifiedAssetTimestamp, + dbAlbum.lastModifiedAssetTimestamp, ); } diff --git a/mobile/lib/services/user.service.dart b/mobile/lib/services/user.service.dart index 9631141c416c9..4c2b3cbbd00fb 100644 --- a/mobile/lib/services/user.service.dart +++ b/mobile/lib/services/user.service.dart @@ -1,68 +1,48 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:immich_mobile/services/partner.service.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/interfaces/partner_api.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:immich_mobile/interfaces/user_api.interface.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/user.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/utils/diff.dart'; -import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; -import 'package:openapi/api.dart'; final userServiceProvider = Provider( (ref) => UserService( - ref.watch(apiServiceProvider), - ref.watch(dbProvider), + ref.watch(partnerApiRepositoryProvider), + ref.watch(userApiRepositoryProvider), + ref.watch(userRepositoryProvider), ref.watch(syncServiceProvider), - ref.watch(partnerServiceProvider), ), ); class UserService { - final ApiService _apiService; - final Isar _db; + final IPartnerApiRepository _partnerApiRepository; + final IUserApiRepository _userApiRepository; + final IUserRepository _userRepository; final SyncService _syncService; - final PartnerService _partnerService; final Logger _log = Logger("UserService"); UserService( - this._apiService, - this._db, + this._partnerApiRepository, + this._userApiRepository, + this._userRepository, this._syncService, - this._partnerService, ); - Future?> _getAllUsers() async { - try { - final dto = await _apiService.usersApi.searchUsers(); - return dto?.map(User.fromSimpleUserDto).toList(); - } catch (e) { - _log.warning("Failed get all users", e); - return null; - } - } + Future> getUsers({bool self = false}) => + _userRepository.getAll(self: self); - Future> getUsersInDb({bool self = false}) async { - if (self) { - return _db.users.where().findAll(); - } - final int userId = Store.get(StoreKey.currentUser).isarId; - return _db.users.where().isarIdNotEqualTo(userId).findAll(); - } - - Future uploadProfileImage(XFile image) async { + Future<({String profileImagePath})?> uploadProfileImage(XFile image) async { try { - return await _apiService.usersApi.createProfileImage( - MultipartFile.fromBytes( - 'file', - await image.readAsBytes(), - filename: image.name, - ), + return await _userApiRepository.createProfileImage( + name: image.name, + data: await image.readAsBytes(), ); } catch (e) { _log.warning("Failed to upload profile image", e); @@ -71,13 +51,19 @@ class UserService { } Future?> getUsersFromServer() async { - final List? users = await _getAllUsers(); - final List? sharedBy = - await _partnerService.getPartners(PartnerDirection.by); - final List? sharedWith = - await _partnerService.getPartners(PartnerDirection.with_); + List? users; + try { + users = await _userApiRepository.getAll(); + } catch (e) { + _log.warning("Failed to fetch users", e); + users = null; + } + final List sharedBy = + await _partnerApiRepository.getAll(Direction.sharedByMe); + final List sharedWith = + await _partnerApiRepository.getAll(Direction.sharedWithMe); - if (users == null || sharedBy == null || sharedWith == null) { + if (users == null) { _log.warning("Failed to refresh users"); return null; } diff --git a/mobile/lib/utils/diff.dart b/mobile/lib/utils/diff.dart index 18e3843819030..a36902d8c7952 100644 --- a/mobile/lib/utils/diff.dart +++ b/mobile/lib/utils/diff.dart @@ -1,16 +1,20 @@ import 'dart:async'; +import 'package:collection/collection.dart'; + /// Efficiently compares two sorted lists in O(n), calling the given callback /// for each item. /// Return `true` if there are any differences found, else `false` -Future diffSortedLists( - List la, - List lb, { - required int Function(A a, B b) compare, - required FutureOr Function(A a, B b) both, - required FutureOr Function(A a) onlyFirst, - required FutureOr Function(B b) onlySecond, +Future diffSortedLists( + List la, + List lb, { + required int Function(T a, T b) compare, + required FutureOr Function(T a, T b) both, + required FutureOr Function(T a) onlyFirst, + required FutureOr Function(T b) onlySecond, }) async { + assert(la.isSorted(compare), "first argument must be sorted"); + assert(lb.isSorted(compare), "second argument must be sorted"); bool diff = false; int i = 0, j = 0; for (; i < la.length && j < lb.length;) { @@ -38,14 +42,16 @@ Future diffSortedLists( /// Efficiently compares two sorted lists in O(n), calling the given callback /// for each item. /// Return `true` if there are any differences found, else `false` -bool diffSortedListsSync( - List la, - List lb, { - required int Function(A a, B b) compare, - required bool Function(A a, B b) both, - required void Function(A a) onlyFirst, - required void Function(B b) onlySecond, +bool diffSortedListsSync( + List la, + List lb, { + required int Function(T a, T b) compare, + required bool Function(T a, T b) both, + required void Function(T a) onlyFirst, + required void Function(T b) onlySecond, }) { + assert(la.isSorted(compare), "first argument must be sorted"); + assert(lb.isSorted(compare), "second argument must be sorted"); bool diff = false; int i = 0, j = 0; for (; i < la.length && j < lb.length;) { diff --git a/mobile/lib/utils/download.dart b/mobile/lib/utils/download.dart new file mode 100644 index 0000000000000..c701f353a2e6e --- /dev/null +++ b/mobile/lib/utils/download.dart @@ -0,0 +1,3 @@ +const downloadGroupImage = 'group_image'; +const downloadGroupVideo = 'group_video'; +const downloadGroupLivePhoto = 'group_livephoto'; diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index e7a1b9e39eefe..9fc7b13eed1c6 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -1,7 +1,7 @@ +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; String getThumbnailUrl( @@ -61,7 +61,7 @@ String getOriginalUrlForRemoteId(final String id) { String getImageCacheKey(final Asset asset) { // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; return '${isFromDto ? asset.remoteId : asset.id}_fullStage'; } diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index 349b2322afac1..255ad01247aa0 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -12,6 +12,29 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'tags', TagsResponse().toJson()); } break; + case 'ServerConfigDto': + if (value is Map) { + addDefault( + value, + 'mapLightStyleUrl', + 'https://tiles.immich.cloud/v1/style/light.json', + ); + addDefault( + value, + 'mapDarkStyleUrl', + 'https://tiles.immich.cloud/v1/style/dark.json', + ); + } + case 'UserResponseDto': + if (value is Map) { + addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); + } + break; + case 'UserAdminResponseDto': + if (value is Map) { + addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); + } + break; } } diff --git a/mobile/lib/utils/provider_utils.dart b/mobile/lib/utils/provider_utils.dart new file mode 100644 index 0000000000000..3eac55089dcd4 --- /dev/null +++ b/mobile/lib/utils/provider_utils.dart @@ -0,0 +1,16 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/repositories/activity_api.repository.dart'; +import 'package:immich_mobile/repositories/album_api.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; +import 'package:immich_mobile/repositories/person_api.repository.dart'; +import 'package:immich_mobile/repositories/user_api.repository.dart'; + +void invalidateAllApiRepositoryProviders(WidgetRef ref) { + ref.invalidate(userApiRepositoryProvider); + ref.invalidate(activityApiRepositoryProvider); + ref.invalidate(partnerApiRepositoryProvider); + ref.invalidate(albumApiRepositoryProvider); + ref.invalidate(personApiRepositoryProvider); + ref.invalidate(assetApiRepositoryProvider); +} diff --git a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart index 4490da7aedac1..746bbde6efd3c 100644 --- a/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart +++ b/mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart @@ -59,8 +59,6 @@ class DraggableScrollbar extends StatefulWidget { final Function(bool scrolling) scrollStateListener; - final double viewPortHeight; - DraggableScrollbar.semicircle({ super.key, Key? scrollThumbKey, @@ -69,7 +67,6 @@ class DraggableScrollbar extends StatefulWidget { required this.controller, required this.itemPositionsListener, required this.scrollStateListener, - required this.viewPortHeight, this.heightScrollThumb = 48.0, this.backgroundColor = Colors.white, this.padding, @@ -254,7 +251,7 @@ class DraggableScrollbarState extends State } double get barMaxScrollExtent => - widget.viewPortHeight - + (context.size?.height ?? 0) - widget.heightScrollThumb - (widget.heightOffset ?? 0); @@ -340,8 +337,8 @@ class DraggableScrollbarState extends State _thumbAnimationController.forward(); } - if (itemPos < maxItemCount) { - _currentItem = itemPos; + if (itemPosition < maxItemCount) { + _currentItem = itemPosition; } _fadeoutTimer?.cancel(); @@ -365,25 +362,35 @@ class DraggableScrollbarState extends State widget.scrollStateListener(true); } - int get itemPos { + int get itemPosition { int numberOfItems = widget.child.itemCount; return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt(); } - void _jumpToBarPos() { - if (itemPos > maxItemCount - 1) { + void _jumpToBarPosition() { + if (itemPosition > maxItemCount - 1) { return; } - _currentItem = itemPos; + _currentItem = itemPosition; + + /// If the bar is at the bottom but the item position is still smaller than the max item count (due to rounding error) + /// jump to the end of the list + if (barMaxScrollExtent - _barOffset < 10 && itemPosition < maxItemCount) { + widget.controller.jumpTo( + index: maxItemCount, + ); + + return; + } widget.controller.jumpTo( - index: itemPos, + index: itemPosition, ); } Timer? dragHaltTimer; - int lastTimerPos = 0; + int lastTimerPosition = 0; void _onVerticalDragUpdate(DragUpdateDetails details) { setState(() { @@ -400,8 +407,8 @@ class DraggableScrollbarState extends State _barOffset = barMaxScrollExtent; } - if (itemPos != lastTimerPos) { - lastTimerPos = itemPos; + if (itemPosition != lastTimerPosition) { + lastTimerPosition = itemPosition; dragHaltTimer?.cancel(); widget.scrollStateListener(true); @@ -413,7 +420,7 @@ class DraggableScrollbarState extends State ); } - _jumpToBarPos(); + _jumpToBarPosition(); } }); } @@ -426,7 +433,7 @@ class DraggableScrollbarState extends State }); setState(() { - _jumpToBarPos(); + _jumpToBarPosition(); _isDragInProcess = false; }); diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 8ae74ba120f59..38e499b5dec8e 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -264,7 +264,6 @@ class ImmichAssetGridViewState extends ConsumerState { final child = (useDragScrolling && ModalRoute.of(context) != null) ? DraggableScrollbar.semicircle( - viewPortHeight: context.height, scrollStateListener: dragScrolling, itemPositionsListener: _itemPositionsListener, controller: _itemScrollController, diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 3263373554df2..14678903ba298 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -131,11 +131,7 @@ class MultiselectGrid extends HookConsumerWidget { processing.value = true; if (shareLocal) { // Share = Download + Send to OS specific share sheet - // Filter offline assets since we cannot fetch their original file - final liveAssets = selection.value.nonOfflineOnly( - errorCallback: errorBuilder('asset_action_share_err_offline'.tr()), - ); - handleShareAssets(ref, context, liveAssets); + handleShareAssets(ref, context, selection.value); } else { final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()) diff --git a/mobile/lib/widgets/asset_grid/thumbnail_image.dart b/mobile/lib/widgets/asset_grid/thumbnail_image.dart index 8e818f64fb7cc..6cadef763d730 100644 --- a/mobile/lib/widgets/asset_grid/thumbnail_image.dart +++ b/mobile/lib/widgets/asset_grid/thumbnail_image.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; -import 'package:isar/isar.dart'; class ThumbnailImage extends ConsumerWidget { /// The asset to show the thumbnail image for @@ -46,7 +46,7 @@ class ThumbnailImage extends ConsumerWidget { ? context.primaryColor.darken(amount: 0.6) : context.primaryColor.lighten(amount: 0.8); // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = asset.id == Isar.autoIncrement; + final isFromDto = asset.id == noDbId; Widget buildSelectionIcon(Asset asset) { if (isSelected) { diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 7e6136c256192..c3f1390dba04a 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; @@ -181,20 +181,12 @@ class BottomGalleryBar extends ConsumerWidget { ); return; } - ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context); + ref.read(downloadStateProvider.notifier).shareAsset(asset, context); } void handleEdit() async { final image = Image(image: ImmichImage.imageProvider(asset: asset)); - if (asset.isOffline) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'asset_action_edit_err_offline'.tr(), - gravity: ToastGravity.BOTTOM, - ); - return; - } + Navigator.of(context).push( MaterialPageRoute( builder: (context) => EditImagePage( @@ -229,7 +221,7 @@ class BottomGalleryBar extends ConsumerWidget { return; } - ref.read(imageViewerStateProvider.notifier).downloadAsset( + ref.read(downloadStateProvider.notifier).downloadAsset( asset, context, ); diff --git a/mobile/lib/widgets/asset_viewer/description_input.dart b/mobile/lib/widgets/asset_viewer/description_input.dart index 18ef394e2d266..3fdd40130a710 100644 --- a/mobile/lib/widgets/asset_viewer/description_input.dart +++ b/mobile/lib/widgets/asset_viewer/description_input.dart @@ -8,7 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/asset_description.service.dart'; +import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:logging/logging.dart'; @@ -29,14 +29,16 @@ class DescriptionInput extends HookConsumerWidget { final focusNode = useFocusNode(); final isFocus = useState(false); final isTextEmpty = useState(controller.text.isEmpty); - final descriptionProvider = ref.watch(assetDescriptionServiceProvider); + final assetService = ref.watch(assetServiceProvider); final owner = ref.watch(currentUserProvider); final hasError = useState(false); final assetWithExif = ref.watch(assetDetailProvider(asset)); useEffect( () { - controller.text = descriptionProvider.getAssetDescription(asset); + assetService + .getDescription(asset) + .then((value) => controller.text = value); return null; }, [assetWithExif.value], @@ -45,7 +47,7 @@ class DescriptionInput extends HookConsumerWidget { submitDescription(String description) async { hasError.value = false; try { - await descriptionProvider.setDescription( + await assetService.setDescription( asset, description, ); diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index 6de8f5da33944..f400224e0a0be 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; -import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget { } handleDownloadAsset() { - ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context); + ref.read(downloadStateProvider.notifier).downloadAsset(asset, context); } return IgnorePointer( diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 2157a1aebbf36..984b61f50cc05 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -183,8 +183,7 @@ class TopControlAppBar extends HookConsumerWidget { if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.livePhotoVideoId != null) buildLivePhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), - if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner) - buildDownloadButton(), + if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) buildAddToAlbumButton(), if (asset.isTrashed) buildRestoreButton(), diff --git a/mobile/lib/widgets/backup/album_info_card.dart b/mobile/lib/widgets/backup/album_info_card.dart index 0c9cd2d89d33c..7b04855809353 100644 --- a/mobile/lib/widgets/backup/album_info_card.dart +++ b/mobile/lib/widgets/backup/album_info_card.dart @@ -183,23 +183,13 @@ class AlbumInfoCard extends HookConsumerWidget { ), Padding( padding: const EdgeInsets.only(top: 2.0), - child: FutureBuilder( - builder: ((context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data.toString() + - (album.isAll - ? " (${'backup_all'.tr()})" - : ""), - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - ), - ); - } - return const Text("0"); - }), - future: album.assetCount, + child: Text( + album.assetCount.toString() + + (album.isAll ? " (${'backup_all'.tr()})" : ""), + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), ), ), ], @@ -208,7 +198,7 @@ class AlbumInfoCard extends HookConsumerWidget { IconButton( onPressed: () { context.pushRoute( - AlbumPreviewRoute(album: album.albumEntity), + AlbumPreviewRoute(album: album.album), ); }, icon: Icon( diff --git a/mobile/lib/widgets/backup/album_info_list_tile.dart b/mobile/lib/widgets/backup/album_info_list_tile.dart index d326bad3e0fc7..a263c004bdf2c 100644 --- a/mobile/lib/widgets/backup/album_info_list_tile.dart +++ b/mobile/lib/widgets/backup/album_info_list_tile.dart @@ -1,6 +1,5 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -24,19 +23,10 @@ class AlbumInfoListTile extends HookConsumerWidget { ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album); - final assetCount = useState(0); final syncAlbum = ref .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.syncAlbums); - useEffect( - () { - album.assetCount.then((value) => assetCount.value = value); - return null; - }, - [album], - ); - buildTileColor() { if (isSelected) { return context.isDarkTheme @@ -117,11 +107,11 @@ class AlbumInfoListTile extends HookConsumerWidget { fontWeight: FontWeight.bold, ), ), - subtitle: Text(assetCount.value.toString()), + subtitle: Text(album.assetCount.toString()), trailing: IconButton( onPressed: () { context.pushRoute( - AlbumPreviewRoute(album: album.albumEntity), + AlbumPreviewRoute(album: album.album), ); }, icon: Icon( diff --git a/mobile/lib/widgets/backup/current_backup_asset_info_box.dart b/mobile/lib/widgets/backup/current_backup_asset_info_box.dart index 8e58905aaa51f..f2f84e271f155 100644 --- a/mobile/lib/widgets/backup/current_backup_asset_info_box.dart +++ b/mobile/lib/widgets/backup/current_backup_asset_info_box.dart @@ -2,18 +2,19 @@ import 'dart:io'; import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; class CurrentUploadingAssetInfoBox extends HookConsumerWidget { const CurrentUploadingAssetInfoBox({super.key}); @@ -148,17 +149,6 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ); } - buildAssetThumbnail() async { - var assetEntity = await AssetEntity.fromId(asset.id); - - if (assetEntity != null) { - return assetEntity.thumbnailDataWithSize( - const ThumbnailSize(500, 500), - quality: 100, - ); - } - } - buildiCloudDownloadProgerssBar() { if (asset.iCloudAsset != null && asset.iCloudAsset!) { return Padding( @@ -239,8 +229,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { ); } - return FutureBuilder( - future: buildAssetThumbnail(), + return FutureBuilder( + future: ref.read(assetMediaRepositoryProvider).get(asset.id), builder: (context, thumbnail) => ListTile( isThreeLine: true, leading: AnimatedCrossFade( @@ -250,9 +240,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { child: thumbnail.hasData ? ClipRRect( borderRadius: BorderRadius.circular(5), - child: Image.memory( - thumbnail.data!, - fit: BoxFit.cover, + child: ImmichThumbnail( + asset: thumbnail.data, width: 50, height: 50, ), diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 1c9713f4d7fc5..cd694336bc5af 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -237,35 +237,40 @@ class ImmichAppBarDialog extends HookConsumerWidget { ); } - return Dialog( - clipBehavior: Clip.hardEdge, - alignment: Alignment.topCenter, - insetPadding: EdgeInsets.only( - top: isHorizontal ? 20 : 40, - left: horizontalPadding, - right: horizontalPadding, - bottom: isHorizontal ? 20 : 100, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: SizedBox( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(20), - child: buildTopRow(), - ), - const AppBarProfileInfoBox(), - buildStorageInformation(), - const AppBarServerInfo(), - buildAppLogButton(), - buildSettingButton(), - buildSignOutButton(), - buildFooter(), - ], + return Dismissible( + direction: DismissDirection.down, + onDismissed: (_) => Navigator.of(context).pop(), + key: const Key('app_bar_dialog'), + child: Dialog( + clipBehavior: Clip.hardEdge, + alignment: Alignment.topCenter, + insetPadding: EdgeInsets.only( + top: isHorizontal ? 20 : 40, + left: horizontalPadding, + right: horizontalPadding, + bottom: isHorizontal ? 20 : 100, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: SizedBox( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(20), + child: buildTopRow(), + ), + const AppBarProfileInfoBox(), + buildStorageInformation(), + const AppBarServerInfo(), + buildAppLogButton(), + buildSettingButton(), + buildSignOutButton(), + buildFooter(), + ], + ), ), ), ), diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 14a4e89dd6066..01b717ef5b977 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/version_compatibility.dart'; import 'package:immich_mobile/widgets/common/immich_logo.dart'; import 'package:immich_mobile/widgets/common/immich_title_text.dart'; @@ -175,7 +176,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { usernameController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; + serverEndpointController.text = 'http://192.168.1.16:2283/api'; } login() async { @@ -186,6 +187,9 @@ class LoginForm extends HookConsumerWidget { // This will remove current cache asset state of previous user login. ref.read(assetProvider.notifier).clearAllAsset(); + // Invalidate all api repository provider instance to take into account new access token + invalidateAllApiRepositoryProviders(ref); + try { final isAuthenticated = await ref.read(authenticationProvider.notifier).login( diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index 55be81a5b3d9b..7f72750afe37f 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -1,5 +1,3 @@ -library photo_view; - import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart'; diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index 9594912078cd6..b8918309bcedf 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -1,5 +1,3 @@ -library photo_view_gallery; - import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart' diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index d79ae5bd95d3b..dfc435c807158 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -3,23 +3,23 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { var imageSize = 45.0; final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return people.widgetWhen( onData: (people) { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index bb845157979b6..1396062bee6b1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.114.0 +- API version: 1.117.0 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen @@ -115,7 +115,7 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*DeprecatedApi* | [**getPersonAssets**](doc//DeprecatedApi.md#getpersonassets) | **GET** /people/{id}/assets | +*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *DuplicatesApi* | [**getAssetDuplicates**](doc//DuplicatesApi.md#getassetduplicates) | **GET** /duplicates | @@ -124,6 +124,7 @@ Class | Method | HTTP request | Description *FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix | *FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports | *FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum | +*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | @@ -131,12 +132,10 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | *LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | *LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | -*LibrariesApi* | [**removeOfflineFiles**](doc//LibrariesApi.md#removeofflinefiles) | **POST** /libraries/{id}/removeOffline | *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | -*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json | *MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | *MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoriesApi* | [**createMemory**](doc//MemoriesApi.md#creatememory) | **POST** /memories | @@ -158,7 +157,6 @@ Class | Method | HTTP request | Description *PeopleApi* | [**createPerson**](doc//PeopleApi.md#createperson) | **POST** /people | *PeopleApi* | [**getAllPeople**](doc//PeopleApi.md#getallpeople) | **GET** /people | *PeopleApi* | [**getPerson**](doc//PeopleApi.md#getperson) | **GET** /people/{id} | -*PeopleApi* | [**getPersonAssets**](doc//PeopleApi.md#getpersonassets) | **GET** /people/{id}/assets | *PeopleApi* | [**getPersonStatistics**](doc//PeopleApi.md#getpersonstatistics) | **GET** /people/{id}/statistics | *PeopleApi* | [**getPersonThumbnail**](doc//PeopleApi.md#getpersonthumbnail) | **GET** /people/{id}/thumbnail | *PeopleApi* | [**mergePerson**](doc//PeopleApi.md#mergeperson) | **POST** /people/{id}/merge | @@ -171,6 +169,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | +*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | *ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | *ServerApi* | [**getAboutInfo**](doc//ServerApi.md#getaboutinfo) | **GET** /server/about | @@ -182,6 +181,7 @@ Class | Method | HTTP request | Description *ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | *ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | *ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | +*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | *ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | @@ -330,6 +330,7 @@ Class | Method | HTTP request | Description - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) - [JobCountsDto](doc//JobCountsDto.md) + - [JobCreateDto](doc//JobCreateDto.md) - [JobName](doc//JobName.md) - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) @@ -341,9 +342,9 @@ Class | Method | HTTP request | Description - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md) + - [ManualJobName](doc//ManualJobName.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - - [MapTheme](doc//MapTheme.md) - [MemoriesResponse](doc//MemoriesResponse.md) - [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoryCreateDto](doc//MemoryCreateDto.md) @@ -376,12 +377,12 @@ Class | Method | HTTP request | Description - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueStatusDto](doc//QueueStatusDto.md) + - [RandomSearchDto](doc//RandomSearchDto.md) - [RatingsResponse](doc//RatingsResponse.md) - [RatingsUpdate](doc//RatingsUpdate.md) - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) - - [ScanLibraryDto](doc//ScanLibraryDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) @@ -398,6 +399,7 @@ Class | Method | HTTP request | Description - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) - [ServerStorageResponseDto](doc//ServerStorageResponseDto.md) - [ServerThemeDto](doc//ServerThemeDto.md) + - [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md) - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) @@ -414,6 +416,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) + - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) @@ -444,11 +447,13 @@ Class | Method | HTTP request | Description - [TagUpsertDto](doc//TagUpsertDto.md) - [TagsResponse](doc//TagsResponse.md) - [TagsUpdate](doc//TagsUpdate.md) + - [TestEmailResponseDto](doc//TestEmailResponseDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) - [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodePolicy](doc//TranscodePolicy.md) + - [TrashResponseDto](doc//TrashResponseDto.md) - [UpdateAlbumDto](doc//UpdateAlbumDto.md) - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 091e900145ab3..6fb7478d04bf2 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -144,6 +144,7 @@ part 'model/image_format.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; part 'model/job_counts_dto.dart'; +part 'model/job_create_dto.dart'; part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; @@ -155,9 +156,9 @@ part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; part 'model/logout_response_dto.dart'; +part 'model/manual_job_name.dart'; part 'model/map_marker_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart'; -part 'model/map_theme.dart'; part 'model/memories_response.dart'; part 'model/memories_update.dart'; part 'model/memory_create_dto.dart'; @@ -190,12 +191,12 @@ part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_status_dto.dart'; +part 'model/random_search_dto.dart'; part 'model/ratings_response.dart'; part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; -part 'model/scan_library_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; part 'model/search_explore_item.dart'; @@ -212,6 +213,7 @@ part 'model/server_ping_response.dart'; part 'model/server_stats_response_dto.dart'; part 'model/server_storage_response_dto.dart'; part 'model/server_theme_dto.dart'; +part 'model/server_version_history_response_dto.dart'; part 'model/server_version_response_dto.dart'; part 'model/session_response_dto.dart'; part 'model/shared_link_create_dto.dart'; @@ -228,6 +230,7 @@ part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; +part 'model/system_config_generated_image_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; part 'model/system_config_library_dto.dart'; @@ -258,11 +261,13 @@ part 'model/tag_update_dto.dart'; part 'model/tag_upsert_dto.dart'; part 'model/tags_response.dart'; part 'model/tags_update.dart'; +part 'model/test_email_response_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; part 'model/transcode_hw_accel.dart'; part 'model/transcode_policy.dart'; +part 'model/trash_response_dto.dart'; part 'model/update_album_dto.dart'; part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index ceba3574cd17a..fd899869803fd 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -449,7 +449,10 @@ class AssetsApi { return null; } - /// Performs an HTTP 'GET /assets/random' operation and returns the [Response]. + /// This property was deprecated in v1.116.0 + /// + /// Note: This method returns the HTTP [Response]. + /// /// Parameters: /// /// * [num] count: @@ -482,6 +485,8 @@ class AssetsApi { ); } + /// This property was deprecated in v1.116.0 + /// /// Parameters: /// /// * [num] count: @@ -828,14 +833,12 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + Future uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { // ignore: prefer_const_declarations final path = r'/assets'; @@ -891,10 +894,6 @@ class AssetsApi { hasFields = true; mp.fields[r'isFavorite'] = parameterToString(isFavorite); } - if (isOffline != null) { - hasFields = true; - mp.fields[r'isOffline'] = parameterToString(isOffline); - } if (isVisible != null) { hasFields = true; mp.fields[r'isVisible'] = parameterToString(isVisible); @@ -946,15 +945,13 @@ class AssetsApi { /// /// * [bool] isFavorite: /// - /// * [bool] isOffline: - /// /// * [bool] isVisible: /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isOffline, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isOffline: isOffline, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); + Future uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index 96cb3c2ef0ad2..30e35b451cfc3 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -16,17 +16,16 @@ class DeprecatedApi { final ApiClient apiClient; - /// This property was deprecated in v1.113.0 + /// This property was deprecated in v1.116.0 /// /// Note: This method returns the HTTP [Response]. /// /// Parameters: /// - /// * [String] id (required): - Future getPersonAssetsWithHttpInfo(String id,) async { + /// * [num] count: + Future getRandomWithHttpInfo({ num? count, }) async { // ignore: prefer_const_declarations - final path = r'/people/{id}/assets' - .replaceAll('{id}', id); + final path = r'/assets/random'; // ignore: prefer_final_locals Object? postBody; @@ -35,6 +34,10 @@ class DeprecatedApi { final headerParams = {}; final formParams = {}; + if (count != null) { + queryParams.addAll(_queryParams('', 'count', count)); + } + const contentTypes = []; @@ -49,13 +52,13 @@ class DeprecatedApi { ); } - /// This property was deprecated in v1.113.0 + /// This property was deprecated in v1.116.0 /// /// Parameters: /// - /// * [String] id (required): - Future?> getPersonAssets(String id,) async { - final response = await getPersonAssetsWithHttpInfo(id,); + /// * [num] count: + Future?> getRandom({ num? count, }) async { + final response = await getRandomWithHttpInfo( count: count, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/jobs_api.dart b/mobile/openapi/lib/api/jobs_api.dart index 5f9501d126f8e..78afc15c93580 100644 --- a/mobile/openapi/lib/api/jobs_api.dart +++ b/mobile/openapi/lib/api/jobs_api.dart @@ -16,6 +16,45 @@ class JobsApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /jobs' operation and returns the [Response]. + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJobWithHttpInfo(JobCreateDto jobCreateDto,) async { + // ignore: prefer_const_declarations + final path = r'/jobs'; + + // ignore: prefer_final_locals + Object? postBody = jobCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [JobCreateDto] jobCreateDto (required): + Future createJob(JobCreateDto jobCreateDto,) async { + final response = await createJobWithHttpInfo(jobCreateDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /jobs' operation and returns the [Response]. Future getAllJobsStatusWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 53ab0e19ce77b..36d98d9a88a78 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -243,65 +243,23 @@ class LibrariesApi { return null; } - /// Performs an HTTP 'POST /libraries/{id}/removeOffline' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - Future removeOfflineFilesWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/libraries/{id}/removeOffline' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - Future removeOfflineFiles(String id,) async { - final response = await removeOfflineFilesWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } - /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibraryWithHttpInfo(String id, ScanLibraryDto scanLibraryDto,) async { + Future scanLibraryWithHttpInfo(String id,) async { // ignore: prefer_const_declarations final path = r'/libraries/{id}/scan' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = scanLibraryDto; + Object? postBody; final queryParams = []; final headerParams = {}; final formParams = {}; - const contentTypes = ['application/json']; + const contentTypes = []; return apiClient.invokeAPI( @@ -318,10 +276,8 @@ class LibrariesApi { /// Parameters: /// /// * [String] id (required): - /// - /// * [ScanLibraryDto] scanLibraryDto (required): - Future scanLibrary(String id, ScanLibraryDto scanLibraryDto,) async { - final response = await scanLibraryWithHttpInfo(id, scanLibraryDto,); + Future scanLibrary(String id,) async { + final response = await scanLibraryWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart index 2846dae6c3582..9644fbfc5c08b 100644 --- a/mobile/openapi/lib/api/map_api.dart +++ b/mobile/openapi/lib/api/map_api.dart @@ -105,62 +105,6 @@ class MapApi { return null; } - /// Performs an HTTP 'GET /map/style.json' operation and returns the [Response]. - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/map/style.json'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - queryParams.addAll(_queryParams('', 'theme', theme)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyle(MapTheme theme, { String? key, }) async { - final response = await getMapStyleWithHttpInfo(theme, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; - - } - return null; - } - /// Performs an HTTP 'GET /map/reverse-geocode' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/notifications_api.dart b/mobile/openapi/lib/api/notifications_api.dart index a3506b9bc1a7f..0681d582479ad 100644 --- a/mobile/openapi/lib/api/notifications_api.dart +++ b/mobile/openapi/lib/api/notifications_api.dart @@ -48,10 +48,18 @@ class NotificationsApi { /// Parameters: /// /// * [SystemConfigSmtpDto] systemConfigSmtpDto (required): - Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { + Future sendTestEmail(SystemConfigSmtpDto systemConfigSmtpDto,) async { final response = await sendTestEmailWithHttpInfo(systemConfigSmtpDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TestEmailResponseDto',) as TestEmailResponseDto; + + } + return null; } } diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 95c4a2fd45cf4..7df0d66c79cfb 100644 --- a/mobile/openapi/lib/api/people_api.dart +++ b/mobile/openapi/lib/api/people_api.dart @@ -180,62 +180,6 @@ class PeopleApi { return null; } - /// This property was deprecated in v1.113.0 - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [String] id (required): - Future getPersonAssetsWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/people/{id}/assets' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// This property was deprecated in v1.113.0 - /// - /// Parameters: - /// - /// * [String] id (required): - Future?> getPersonAssets(String id,) async { - final response = await getPersonAssetsWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// Performs an HTTP 'GET /people/{id}/statistics' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 4b6cdfea78aa4..985029f106d27 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -351,6 +351,56 @@ class SearchApi { return null; } + /// Performs an HTTP 'POST /search/random' operation and returns the [Response]. + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future searchRandomWithHttpInfo(RandomSearchDto randomSearchDto,) async { + // ignore: prefer_const_declarations + final path = r'/search/random'; + + // ignore: prefer_final_locals + Object? postBody = randomSearchDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [RandomSearchDto] randomSearchDto (required): + Future?> searchRandom(RandomSearchDto randomSearchDto,) async { + final response = await searchRandomWithHttpInfo(randomSearchDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'POST /search/smart' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart index bde8d595b6fb0..7a832ad61a158 100644 --- a/mobile/openapi/lib/api/server_api.dart +++ b/mobile/openapi/lib/api/server_api.dart @@ -418,6 +418,50 @@ class ServerApi { return null; } + /// Performs an HTTP 'GET /server/version-history' operation and returns the [Response]. + Future getVersionHistoryWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/version-history'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getVersionHistory() async { + final response = await getVersionHistoryWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'GET /server/ping' operation and returns the [Response]. Future pingServerWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api/trash_api.dart b/mobile/openapi/lib/api/trash_api.dart index 9c346870ecea1..8f8c6ffb3a190 100644 --- a/mobile/openapi/lib/api/trash_api.dart +++ b/mobile/openapi/lib/api/trash_api.dart @@ -42,11 +42,19 @@ class TrashApi { ); } - Future emptyTrash() async { + Future emptyTrash() async { final response = await emptyTrashWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto; + + } + return null; } /// Performs an HTTP 'POST /trash/restore/assets' operation and returns the [Response]. @@ -81,11 +89,19 @@ class TrashApi { /// Parameters: /// /// * [BulkIdsDto] bulkIdsDto (required): - Future restoreAssets(BulkIdsDto bulkIdsDto,) async { + Future restoreAssets(BulkIdsDto bulkIdsDto,) async { final response = await restoreAssetsWithHttpInfo(bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto; + + } + return null; } /// Performs an HTTP 'POST /trash/restore' operation and returns the [Response]. @@ -114,10 +130,18 @@ class TrashApi { ); } - Future restoreTrash() async { + Future restoreTrash() async { final response = await restoreTrashWithHttpInfo(); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto; + + } + return null; } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9ec00aecc87aa..c1025b0bd4820 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -166,7 +166,6 @@ class ApiClient { /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); try { switch (targetType) { case 'String': @@ -343,6 +342,8 @@ class ApiClient { return JobCommandDto.fromJson(value); case 'JobCountsDto': return JobCountsDto.fromJson(value); + case 'JobCreateDto': + return JobCreateDto.fromJson(value); case 'JobName': return JobNameTypeTransformer().decode(value); case 'JobSettingsDto': @@ -365,12 +366,12 @@ class ApiClient { return LoginResponseDto.fromJson(value); case 'LogoutResponseDto': return LogoutResponseDto.fromJson(value); + case 'ManualJobName': + return ManualJobNameTypeTransformer().decode(value); case 'MapMarkerResponseDto': return MapMarkerResponseDto.fromJson(value); case 'MapReverseGeocodeResponseDto': return MapReverseGeocodeResponseDto.fromJson(value); - case 'MapTheme': - return MapThemeTypeTransformer().decode(value); case 'MemoriesResponse': return MemoriesResponse.fromJson(value); case 'MemoriesUpdate': @@ -435,6 +436,8 @@ class ApiClient { return PurchaseUpdate.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); + case 'RandomSearchDto': + return RandomSearchDto.fromJson(value); case 'RatingsResponse': return RatingsResponse.fromJson(value); case 'RatingsUpdate': @@ -445,8 +448,6 @@ class ApiClient { return ReactionTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); - case 'ScanLibraryDto': - return ScanLibraryDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetResponseDto': @@ -479,6 +480,8 @@ class ApiClient { return ServerStorageResponseDto.fromJson(value); case 'ServerThemeDto': return ServerThemeDto.fromJson(value); + case 'ServerVersionHistoryResponseDto': + return ServerVersionHistoryResponseDto.fromJson(value); case 'ServerVersionResponseDto': return ServerVersionResponseDto.fromJson(value); case 'SessionResponseDto': @@ -511,6 +514,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigFacesDto': return SystemConfigFacesDto.fromJson(value); + case 'SystemConfigGeneratedImageDto': + return SystemConfigGeneratedImageDto.fromJson(value); case 'SystemConfigImageDto': return SystemConfigImageDto.fromJson(value); case 'SystemConfigJobDto': @@ -571,6 +576,8 @@ class ApiClient { return TagsResponse.fromJson(value); case 'TagsUpdate': return TagsUpdate.fromJson(value); + case 'TestEmailResponseDto': + return TestEmailResponseDto.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': @@ -581,6 +588,8 @@ class ApiClient { return TranscodeHWAccelTypeTransformer().decode(value); case 'TranscodePolicy': return TranscodePolicyTypeTransformer().decode(value); + case 'TrashResponseDto': + return TrashResponseDto.fromJson(value); case 'UpdateAlbumDto': return UpdateAlbumDto.fromJson(value); case 'UpdateAlbumUserDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 8dcef880f59a4..b7c6ad5e010d3 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -97,8 +97,8 @@ String parameterToString(dynamic value) { if (value is LogLevel) { return LogLevelTypeTransformer().encode(value).toString(); } - if (value is MapTheme) { - return MapThemeTypeTransformer().encode(value).toString(); + if (value is ManualJobName) { + return ManualJobNameTypeTransformer().encode(value).toString(); } if (value is MemoryType) { return MemoryTypeTypeTransformer().encode(value).toString(); diff --git a/mobile/openapi/lib/model/activity_create_dto.dart b/mobile/openapi/lib/model/activity_create_dto.dart index b54fa2ca72bce..ce4b4a01766a4 100644 --- a/mobile/openapi/lib/model/activity_create_dto.dart +++ b/mobile/openapi/lib/model/activity_create_dto.dart @@ -78,6 +78,7 @@ class ActivityCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityCreateDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_response_dto.dart b/mobile/openapi/lib/model/activity_response_dto.dart index bfffd8485b0a9..25fb0f53f8707 100644 --- a/mobile/openapi/lib/model/activity_response_dto.dart +++ b/mobile/openapi/lib/model/activity_response_dto.dart @@ -78,6 +78,7 @@ class ActivityResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/activity_statistics_response_dto.dart b/mobile/openapi/lib/model/activity_statistics_response_dto.dart index 20d4696b1b60e..ad0b814a58774 100644 --- a/mobile/openapi/lib/model/activity_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/activity_statistics_response_dto.dart @@ -40,6 +40,7 @@ class ActivityStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ActivityStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ActivityStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/add_users_dto.dart b/mobile/openapi/lib/model/add_users_dto.dart index 2daa571265da6..531c1ec785b45 100644 --- a/mobile/openapi/lib/model/add_users_dto.dart +++ b/mobile/openapi/lib/model/add_users_dto.dart @@ -40,6 +40,7 @@ class AddUsersDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AddUsersDto? fromJson(dynamic value) { + upgradeDto(value, "AddUsersDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart index 2277f0958c813..298bf318a292b 100644 --- a/mobile/openapi/lib/model/admin_onboarding_update_dto.dart +++ b/mobile/openapi/lib/model/admin_onboarding_update_dto.dart @@ -40,6 +40,7 @@ class AdminOnboardingUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AdminOnboardingUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AdminOnboardingUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index c98a95775d2c8..547a6a70fd221 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -186,6 +186,7 @@ class AlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_statistics_response_dto.dart b/mobile/openapi/lib/model/album_statistics_response_dto.dart index 90dbe520163bb..9e19002cf18c2 100644 --- a/mobile/openapi/lib/model/album_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/album_statistics_response_dto.dart @@ -52,6 +52,7 @@ class AlbumStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_add_dto.dart b/mobile/openapi/lib/model/album_user_add_dto.dart index e654a2ff5d7b0..3f72d5c893e18 100644 --- a/mobile/openapi/lib/model/album_user_add_dto.dart +++ b/mobile/openapi/lib/model/album_user_add_dto.dart @@ -56,6 +56,7 @@ class AlbumUserAddDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserAddDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserAddDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_create_dto.dart b/mobile/openapi/lib/model/album_user_create_dto.dart index 708acd472beed..93a0661b30251 100644 --- a/mobile/openapi/lib/model/album_user_create_dto.dart +++ b/mobile/openapi/lib/model/album_user_create_dto.dart @@ -46,6 +46,7 @@ class AlbumUserCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserCreateDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/album_user_response_dto.dart b/mobile/openapi/lib/model/album_user_response_dto.dart index 8f86cf254ea92..bbae03fba74c3 100644 --- a/mobile/openapi/lib/model/album_user_response_dto.dart +++ b/mobile/openapi/lib/model/album_user_response_dto.dart @@ -46,6 +46,7 @@ class AlbumUserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AlbumUserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AlbumUserResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 1ee5253c38bde..6ec248a638e80 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -118,6 +118,7 @@ class AllJobStatusResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AllJobStatusResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AllJobStatusResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index 433855c4cfe17..848774e9c9cf7 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -56,6 +56,7 @@ class APIKeyCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_create_response_dto.dart b/mobile/openapi/lib/model/api_key_create_response_dto.dart index 93065654ac331..cdaa70e37de71 100644 --- a/mobile/openapi/lib/model/api_key_create_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_response_dto.dart @@ -46,6 +46,7 @@ class APIKeyCreateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyCreateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyCreateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index b6ca86c050944..fd0d91f6737e3 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -64,6 +64,7 @@ class APIKeyResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyResponseDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/api_key_update_dto.dart b/mobile/openapi/lib/model/api_key_update_dto.dart index 318f4936e16b2..7295d1ea1f19b 100644 --- a/mobile/openapi/lib/model/api_key_update_dto.dart +++ b/mobile/openapi/lib/model/api_key_update_dto.dart @@ -40,6 +40,7 @@ class APIKeyUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static APIKeyUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "APIKeyUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart index 0f6913a7f4ebc..c4453054b1b92 100644 --- a/mobile/openapi/lib/model/asset_bulk_delete_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_delete_dto.dart @@ -56,6 +56,7 @@ class AssetBulkDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index c9b21683fbcec..da23d2f09d2e0 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -148,6 +148,7 @@ class AssetBulkUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart index 55ea41b598e75..36c13bfdf6889 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart index 16294cdae6d06..13dfa340fad0c 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_item.dart @@ -47,6 +47,7 @@ class AssetBulkUploadCheckItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckItem? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart index 5bfacbff570d2..8c3651e9fa189 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_response_dto.dart @@ -40,6 +40,7 @@ class AssetBulkUploadCheckResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart index 737186e5898ee..88e46dae7daa7 100644 --- a/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart +++ b/mobile/openapi/lib/model/asset_bulk_upload_check_result.dart @@ -16,6 +16,7 @@ class AssetBulkUploadCheckResult { required this.action, this.assetId, required this.id, + this.isTrashed, this.reason, }); @@ -31,6 +32,14 @@ class AssetBulkUploadCheckResult { String id; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isTrashed; + AssetBulkUploadCheckResultReasonEnum? reason; @override @@ -38,6 +47,7 @@ class AssetBulkUploadCheckResult { other.action == action && other.assetId == assetId && other.id == id && + other.isTrashed == isTrashed && other.reason == reason; @override @@ -46,10 +56,11 @@ class AssetBulkUploadCheckResult { (action.hashCode) + (assetId == null ? 0 : assetId!.hashCode) + (id.hashCode) + + (isTrashed == null ? 0 : isTrashed!.hashCode) + (reason == null ? 0 : reason!.hashCode); @override - String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, reason=$reason]'; + String toString() => 'AssetBulkUploadCheckResult[action=$action, assetId=$assetId, id=$id, isTrashed=$isTrashed, reason=$reason]'; Map toJson() { final json = {}; @@ -60,6 +71,11 @@ class AssetBulkUploadCheckResult { // json[r'assetId'] = null; } json[r'id'] = this.id; + if (this.isTrashed != null) { + json[r'isTrashed'] = this.isTrashed; + } else { + // json[r'isTrashed'] = null; + } if (this.reason != null) { json[r'reason'] = this.reason; } else { @@ -72,6 +88,7 @@ class AssetBulkUploadCheckResult { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetBulkUploadCheckResult? fromJson(dynamic value) { + upgradeDto(value, "AssetBulkUploadCheckResult"); if (value is Map) { final json = value.cast(); @@ -79,6 +96,7 @@ class AssetBulkUploadCheckResult { action: AssetBulkUploadCheckResultActionEnum.fromJson(json[r'action'])!, assetId: mapValueOfType(json, r'assetId'), id: mapValueOfType(json, r'id')!, + isTrashed: mapValueOfType(json, r'isTrashed'), reason: AssetBulkUploadCheckResultReasonEnum.fromJson(json[r'reason']), ); } diff --git a/mobile/openapi/lib/model/asset_delta_sync_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_dto.dart index a5ee10f33e17e..845aadcdcd3b4 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_dto.dart @@ -46,6 +46,7 @@ class AssetDeltaSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart index 3b14fa68cf8e7..a64e1a2fbee42 100644 --- a/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart +++ b/mobile/openapi/lib/model/asset_delta_sync_response_dto.dart @@ -52,6 +52,7 @@ class AssetDeltaSyncResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetDeltaSyncResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetDeltaSyncResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_response_dto.dart b/mobile/openapi/lib/model/asset_face_response_dto.dart index 7a8588ce5c4af..c05b511649236 100644 --- a/mobile/openapi/lib/model/asset_face_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_response_dto.dart @@ -102,6 +102,7 @@ class AssetFaceResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart index 58def49ae1ae4..71bdde8e9a6a3 100644 --- a/mobile/openapi/lib/model/asset_face_update_dto.dart +++ b/mobile/openapi/lib/model/asset_face_update_dto.dart @@ -40,6 +40,7 @@ class AssetFaceUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_update_item.dart b/mobile/openapi/lib/model/asset_face_update_item.dart index 5ea37ea4db5f5..c2c48032595dc 100644 --- a/mobile/openapi/lib/model/asset_face_update_item.dart +++ b/mobile/openapi/lib/model/asset_face_update_item.dart @@ -46,6 +46,7 @@ class AssetFaceUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart index ecfe06bd7d6ce..8bf07e15347ca 100644 --- a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart +++ b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart @@ -92,6 +92,7 @@ class AssetFaceWithoutPersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFaceWithoutPersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_full_sync_dto.dart b/mobile/openapi/lib/model/asset_full_sync_dto.dart index e80638f6b0869..7151094b9588c 100644 --- a/mobile/openapi/lib/model/asset_full_sync_dto.dart +++ b/mobile/openapi/lib/model/asset_full_sync_dto.dart @@ -79,6 +79,7 @@ class AssetFullSyncDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetFullSyncDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFullSyncDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_dto.dart b/mobile/openapi/lib/model/asset_ids_dto.dart index c8c7a69b8907c..b44888f3962b3 100644 --- a/mobile/openapi/lib/model/asset_ids_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_dto.dart @@ -40,6 +40,7 @@ class AssetIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_ids_response_dto.dart b/mobile/openapi/lib/model/asset_ids_response_dto.dart index a642c0924cba8..ff63091caa577 100644 --- a/mobile/openapi/lib/model/asset_ids_response_dto.dart +++ b/mobile/openapi/lib/model/asset_ids_response_dto.dart @@ -56,6 +56,7 @@ class AssetIdsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetIdsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetIdsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_job_name.dart b/mobile/openapi/lib/model/asset_job_name.dart index a5b42f4ee52eb..11e0555b868d4 100644 --- a/mobile/openapi/lib/model/asset_job_name.dart +++ b/mobile/openapi/lib/model/asset_job_name.dart @@ -23,14 +23,16 @@ class AssetJobName { String toJson() => value; - static const regenerateThumbnail = AssetJobName._(r'regenerate-thumbnail'); + static const refreshFaces = AssetJobName._(r'refresh-faces'); static const refreshMetadata = AssetJobName._(r'refresh-metadata'); + static const regenerateThumbnail = AssetJobName._(r'regenerate-thumbnail'); static const transcodeVideo = AssetJobName._(r'transcode-video'); /// List of all possible values in this [enum][AssetJobName]. static const values = [ - regenerateThumbnail, + refreshFaces, refreshMetadata, + regenerateThumbnail, transcodeVideo, ]; @@ -70,8 +72,9 @@ class AssetJobNameTypeTransformer { AssetJobName? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { - case r'regenerate-thumbnail': return AssetJobName.regenerateThumbnail; + case r'refresh-faces': return AssetJobName.refreshFaces; case r'refresh-metadata': return AssetJobName.refreshMetadata; + case r'regenerate-thumbnail': return AssetJobName.regenerateThumbnail; case r'transcode-video': return AssetJobName.transcodeVideo; default: if (!allowNull) { diff --git a/mobile/openapi/lib/model/asset_jobs_dto.dart b/mobile/openapi/lib/model/asset_jobs_dto.dart index 16ed2644fd6cd..0f8bfab009fa1 100644 --- a/mobile/openapi/lib/model/asset_jobs_dto.dart +++ b/mobile/openapi/lib/model/asset_jobs_dto.dart @@ -46,6 +46,7 @@ class AssetJobsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetJobsDto? fromJson(dynamic value) { + upgradeDto(value, "AssetJobsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_media_response_dto.dart b/mobile/openapi/lib/model/asset_media_response_dto.dart index c2801c93cce55..75428ec5f61d8 100644 --- a/mobile/openapi/lib/model/asset_media_response_dto.dart +++ b/mobile/openapi/lib/model/asset_media_response_dto.dart @@ -46,6 +46,7 @@ class AssetMediaResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetMediaResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMediaResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index bfb461efdc4c5..c11dedcbfd230 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -293,6 +293,7 @@ class AssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stack_response_dto.dart b/mobile/openapi/lib/model/asset_stack_response_dto.dart index 89d30f7810682..bb4becb129c17 100644 --- a/mobile/openapi/lib/model/asset_stack_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stack_response_dto.dart @@ -52,6 +52,7 @@ class AssetStackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/asset_stats_response_dto.dart b/mobile/openapi/lib/model/asset_stats_response_dto.dart index c21d7fdbffa4c..d11ce55a5cc5c 100644 --- a/mobile/openapi/lib/model/asset_stats_response_dto.dart +++ b/mobile/openapi/lib/model/asset_stats_response_dto.dart @@ -52,6 +52,7 @@ class AssetStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AssetStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/audit_deletes_response_dto.dart b/mobile/openapi/lib/model/audit_deletes_response_dto.dart index 690a52e811466..6b1df74eb4d79 100644 --- a/mobile/openapi/lib/model/audit_deletes_response_dto.dart +++ b/mobile/openapi/lib/model/audit_deletes_response_dto.dart @@ -46,6 +46,7 @@ class AuditDeletesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AuditDeletesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AuditDeletesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_response.dart b/mobile/openapi/lib/model/avatar_response.dart index edd242df4e3be..8ce0287565f2d 100644 --- a/mobile/openapi/lib/model/avatar_response.dart +++ b/mobile/openapi/lib/model/avatar_response.dart @@ -40,6 +40,7 @@ class AvatarResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarResponse? fromJson(dynamic value) { + upgradeDto(value, "AvatarResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/avatar_update.dart b/mobile/openapi/lib/model/avatar_update.dart index b92eb8dcbdb2d..875eb138a8dbf 100644 --- a/mobile/openapi/lib/model/avatar_update.dart +++ b/mobile/openapi/lib/model/avatar_update.dart @@ -50,6 +50,7 @@ class AvatarUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static AvatarUpdate? fromJson(dynamic value) { + upgradeDto(value, "AvatarUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_id_response_dto.dart b/mobile/openapi/lib/model/bulk_id_response_dto.dart index ef3cf2e0dbe00..67a587e8d001e 100644 --- a/mobile/openapi/lib/model/bulk_id_response_dto.dart +++ b/mobile/openapi/lib/model/bulk_id_response_dto.dart @@ -56,6 +56,7 @@ class BulkIdResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdResponseDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/bulk_ids_dto.dart b/mobile/openapi/lib/model/bulk_ids_dto.dart index 6942875f0a2e6..6a7f8ceeec6f6 100644 --- a/mobile/openapi/lib/model/bulk_ids_dto.dart +++ b/mobile/openapi/lib/model/bulk_ids_dto.dart @@ -40,6 +40,7 @@ class BulkIdsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static BulkIdsDto? fromJson(dynamic value) { + upgradeDto(value, "BulkIdsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/change_password_dto.dart b/mobile/openapi/lib/model/change_password_dto.dart index 1074aaf74d4f8..33b7f4a607390 100644 --- a/mobile/openapi/lib/model/change_password_dto.dart +++ b/mobile/openapi/lib/model/change_password_dto.dart @@ -46,6 +46,7 @@ class ChangePasswordDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ChangePasswordDto? fromJson(dynamic value) { + upgradeDto(value, "ChangePasswordDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_dto.dart b/mobile/openapi/lib/model/check_existing_assets_dto.dart index 49ef36cc093e0..42ce6d5c3ea0c 100644 --- a/mobile/openapi/lib/model/check_existing_assets_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_dto.dart @@ -46,6 +46,7 @@ class CheckExistingAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart index d8b0f43a6d0e1..ad93578ebc34c 100644 --- a/mobile/openapi/lib/model/check_existing_assets_response_dto.dart +++ b/mobile/openapi/lib/model/check_existing_assets_response_dto.dart @@ -40,6 +40,7 @@ class CheckExistingAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CheckExistingAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CheckExistingAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/clip_config.dart b/mobile/openapi/lib/model/clip_config.dart index 6e95c15fbfbe2..b500d20f2e6eb 100644 --- a/mobile/openapi/lib/model/clip_config.dart +++ b/mobile/openapi/lib/model/clip_config.dart @@ -46,6 +46,7 @@ class CLIPConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CLIPConfig? fromJson(dynamic value) { + upgradeDto(value, "CLIPConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_album_dto.dart b/mobile/openapi/lib/model/create_album_dto.dart index fa28b782acee9..ff8c1df647fdc 100644 --- a/mobile/openapi/lib/model/create_album_dto.dart +++ b/mobile/openapi/lib/model/create_album_dto.dart @@ -68,6 +68,7 @@ class CreateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "CreateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index 65ceec8e8a4f4..bffa5f427950d 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -68,6 +68,7 @@ class CreateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "CreateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/create_profile_image_response_dto.dart b/mobile/openapi/lib/model/create_profile_image_response_dto.dart index c9ae3ea6516bd..ee98142e86097 100644 --- a/mobile/openapi/lib/model/create_profile_image_response_dto.dart +++ b/mobile/openapi/lib/model/create_profile_image_response_dto.dart @@ -13,30 +13,36 @@ part of openapi.api; class CreateProfileImageResponseDto { /// Returns a new [CreateProfileImageResponseDto] instance. CreateProfileImageResponseDto({ + required this.profileChangedAt, required this.profileImagePath, required this.userId, }); + DateTime profileChangedAt; + String profileImagePath; String userId; @override bool operator ==(Object other) => identical(this, other) || other is CreateProfileImageResponseDto && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath && other.userId == userId; @override int get hashCode => // ignore: unnecessary_parenthesis + (profileChangedAt.hashCode) + (profileImagePath.hashCode) + (userId.hashCode); @override - String toString() => 'CreateProfileImageResponseDto[profileImagePath=$profileImagePath, userId=$userId]'; + String toString() => 'CreateProfileImageResponseDto[profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, userId=$userId]'; Map toJson() { final json = {}; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; json[r'userId'] = this.userId; return json; @@ -46,10 +52,12 @@ class CreateProfileImageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static CreateProfileImageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "CreateProfileImageResponseDto"); if (value is Map) { final json = value.cast(); return CreateProfileImageResponseDto( + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, userId: mapValueOfType(json, r'userId')!, ); @@ -99,6 +107,7 @@ class CreateProfileImageResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'profileChangedAt', 'profileImagePath', 'userId', }; diff --git a/mobile/openapi/lib/model/download_archive_info.dart b/mobile/openapi/lib/model/download_archive_info.dart index e324850bdcf0b..5f3fd1a8c1f3d 100644 --- a/mobile/openapi/lib/model/download_archive_info.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -46,6 +46,7 @@ class DownloadArchiveInfo { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadArchiveInfo? fromJson(dynamic value) { + upgradeDto(value, "DownloadArchiveInfo"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_info_dto.dart b/mobile/openapi/lib/model/download_info_dto.dart index 4c387690102a7..6f4777975c6b2 100644 --- a/mobile/openapi/lib/model/download_info_dto.dart +++ b/mobile/openapi/lib/model/download_info_dto.dart @@ -89,6 +89,7 @@ class DownloadInfoDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadInfoDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadInfoDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart index 25c5159a8b655..041da44b718bc 100644 --- a/mobile/openapi/lib/model/download_response.dart +++ b/mobile/openapi/lib/model/download_response.dart @@ -46,6 +46,7 @@ class DownloadResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponse? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart index f32cba92537ac..5c6bd112661e8 100644 --- a/mobile/openapi/lib/model/download_response_dto.dart +++ b/mobile/openapi/lib/model/download_response_dto.dart @@ -46,6 +46,7 @@ class DownloadResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DownloadResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart index 2c3839a6878dc..8df825a922315 100644 --- a/mobile/openapi/lib/model/download_update.dart +++ b/mobile/openapi/lib/model/download_update.dart @@ -67,6 +67,7 @@ class DownloadUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DownloadUpdate? fromJson(dynamic value) { + upgradeDto(value, "DownloadUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_detection_config.dart b/mobile/openapi/lib/model/duplicate_detection_config.dart index 0bc60917848c4..e4fc352028ec0 100644 --- a/mobile/openapi/lib/model/duplicate_detection_config.dart +++ b/mobile/openapi/lib/model/duplicate_detection_config.dart @@ -48,6 +48,7 @@ class DuplicateDetectionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateDetectionConfig? fromJson(dynamic value) { + upgradeDto(value, "DuplicateDetectionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/duplicate_response_dto.dart b/mobile/openapi/lib/model/duplicate_response_dto.dart index b93ecfe5f5775..6ac7c468711e0 100644 --- a/mobile/openapi/lib/model/duplicate_response_dto.dart +++ b/mobile/openapi/lib/model/duplicate_response_dto.dart @@ -46,6 +46,7 @@ class DuplicateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static DuplicateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "DuplicateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_response.dart b/mobile/openapi/lib/model/email_notifications_response.dart index cef92957c6d78..d6dcfb9273be6 100644 --- a/mobile/openapi/lib/model/email_notifications_response.dart +++ b/mobile/openapi/lib/model/email_notifications_response.dart @@ -52,6 +52,7 @@ class EmailNotificationsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsResponse? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/email_notifications_update.dart b/mobile/openapi/lib/model/email_notifications_update.dart index dcd1ec432206d..dad0a52fdef28 100644 --- a/mobile/openapi/lib/model/email_notifications_update.dart +++ b/mobile/openapi/lib/model/email_notifications_update.dart @@ -82,6 +82,7 @@ class EmailNotificationsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static EmailNotificationsUpdate? fromJson(dynamic value) { + upgradeDto(value, "EmailNotificationsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 0185f300fac5b..17397b20815e0 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -254,6 +254,7 @@ class ExifResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ExifResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ExifResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/face_dto.dart b/mobile/openapi/lib/model/face_dto.dart index 4fcc86debf22e..c84a518b8c784 100644 --- a/mobile/openapi/lib/model/face_dto.dart +++ b/mobile/openapi/lib/model/face_dto.dart @@ -40,6 +40,7 @@ class FaceDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FaceDto? fromJson(dynamic value) { + upgradeDto(value, "FaceDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/facial_recognition_config.dart b/mobile/openapi/lib/model/facial_recognition_config.dart index 52400fd7e135d..439efbbfaeeb1 100644 --- a/mobile/openapi/lib/model/facial_recognition_config.dart +++ b/mobile/openapi/lib/model/facial_recognition_config.dart @@ -22,14 +22,14 @@ class FacialRecognitionConfig { bool enabled; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 2 double maxDistance; /// Minimum value: 1 int minFaces; - /// Minimum value: 0 + /// Minimum value: 0.1 /// Maximum value: 1 double minScore; @@ -69,6 +69,7 @@ class FacialRecognitionConfig { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FacialRecognitionConfig? fromJson(dynamic value) { + upgradeDto(value, "FacialRecognitionConfig"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart index c7e8aa1da60b3..7dc9ccdf2f93e 100644 --- a/mobile/openapi/lib/model/file_checksum_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_dto.dart @@ -40,6 +40,7 @@ class FileChecksumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart index d4bae3c273ddc..7b963c8bd539e 100644 --- a/mobile/openapi/lib/model/file_checksum_response_dto.dart +++ b/mobile/openapi/lib/model/file_checksum_response_dto.dart @@ -46,6 +46,7 @@ class FileChecksumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileChecksumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "FileChecksumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart index 422215ff6c63e..3dc892e5e7f78 100644 --- a/mobile/openapi/lib/model/file_report_dto.dart +++ b/mobile/openapi/lib/model/file_report_dto.dart @@ -46,6 +46,7 @@ class FileReportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart index cf09242b0fa15..d46cdeb4b784b 100644 --- a/mobile/openapi/lib/model/file_report_fix_dto.dart +++ b/mobile/openapi/lib/model/file_report_fix_dto.dart @@ -40,6 +40,7 @@ class FileReportFixDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportFixDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportFixDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart index 5255005daaaf1..1ef08c2b48511 100644 --- a/mobile/openapi/lib/model/file_report_item_dto.dart +++ b/mobile/openapi/lib/model/file_report_item_dto.dart @@ -74,6 +74,7 @@ class FileReportItemDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FileReportItemDto? fromJson(dynamic value) { + upgradeDto(value, "FileReportItemDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_response.dart b/mobile/openapi/lib/model/folders_response.dart index 5bfc4c793deed..248b64b054c83 100644 --- a/mobile/openapi/lib/model/folders_response.dart +++ b/mobile/openapi/lib/model/folders_response.dart @@ -46,6 +46,7 @@ class FoldersResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersResponse? fromJson(dynamic value) { + upgradeDto(value, "FoldersResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/folders_update.dart b/mobile/openapi/lib/model/folders_update.dart index 088c98a4d8fd2..02347177545d0 100644 --- a/mobile/openapi/lib/model/folders_update.dart +++ b/mobile/openapi/lib/model/folders_update.dart @@ -66,6 +66,7 @@ class FoldersUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static FoldersUpdate? fromJson(dynamic value) { + upgradeDto(value, "FoldersUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index 5c56715644f22..32274037f6d5b 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -14,12 +14,18 @@ class JobCommandDto { /// Returns a new [JobCommandDto] instance. JobCommandDto({ required this.command, - required this.force, + this.force, }); JobCommand command; - bool force; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? force; @override bool operator ==(Object other) => identical(this, other) || other is JobCommandDto && @@ -30,7 +36,7 @@ class JobCommandDto { int get hashCode => // ignore: unnecessary_parenthesis (command.hashCode) + - (force.hashCode); + (force == null ? 0 : force!.hashCode); @override String toString() => 'JobCommandDto[command=$command, force=$force]'; @@ -38,7 +44,11 @@ class JobCommandDto { Map toJson() { final json = {}; json[r'command'] = this.command; + if (this.force != null) { json[r'force'] = this.force; + } else { + // json[r'force'] = null; + } return json; } @@ -46,12 +56,13 @@ class JobCommandDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCommandDto? fromJson(dynamic value) { + upgradeDto(value, "JobCommandDto"); if (value is Map) { final json = value.cast(); return JobCommandDto( command: JobCommand.fromJson(json[r'command'])!, - force: mapValueOfType(json, r'force')!, + force: mapValueOfType(json, r'force'), ); } return null; @@ -100,7 +111,6 @@ class JobCommandDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'command', - 'force', }; } diff --git a/mobile/openapi/lib/model/job_counts_dto.dart b/mobile/openapi/lib/model/job_counts_dto.dart index cf1d0b457d821..afc90d108467d 100644 --- a/mobile/openapi/lib/model/job_counts_dto.dart +++ b/mobile/openapi/lib/model/job_counts_dto.dart @@ -70,6 +70,7 @@ class JobCountsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobCountsDto? fromJson(dynamic value) { + upgradeDto(value, "JobCountsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_create_dto.dart b/mobile/openapi/lib/model/job_create_dto.dart new file mode 100644 index 0000000000000..fe6743cba09d9 --- /dev/null +++ b/mobile/openapi/lib/model/job_create_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class JobCreateDto { + /// Returns a new [JobCreateDto] instance. + JobCreateDto({ + required this.name, + }); + + ManualJobName name; + + @override + bool operator ==(Object other) => identical(this, other) || other is JobCreateDto && + other.name == name; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (name.hashCode); + + @override + String toString() => 'JobCreateDto[name=$name]'; + + Map toJson() { + final json = {}; + json[r'name'] = this.name; + return json; + } + + /// Returns a new [JobCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static JobCreateDto? fromJson(dynamic value) { + upgradeDto(value, "JobCreateDto"); + if (value is Map) { + final json = value.cast(); + + return JobCreateDto( + name: ManualJobName.fromJson(json[r'name'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = JobCreateDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = JobCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of JobCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = JobCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'name', + }; +} + diff --git a/mobile/openapi/lib/model/job_settings_dto.dart b/mobile/openapi/lib/model/job_settings_dto.dart index 9c59d503cac85..af354bef9e953 100644 --- a/mobile/openapi/lib/model/job_settings_dto.dart +++ b/mobile/openapi/lib/model/job_settings_dto.dart @@ -41,6 +41,7 @@ class JobSettingsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobSettingsDto? fromJson(dynamic value) { + upgradeDto(value, "JobSettingsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/job_status_dto.dart b/mobile/openapi/lib/model/job_status_dto.dart index fd925bd53a5fc..18fab8dfb3fe2 100644 --- a/mobile/openapi/lib/model/job_status_dto.dart +++ b/mobile/openapi/lib/model/job_status_dto.dart @@ -46,6 +46,7 @@ class JobStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static JobStatusDto? fromJson(dynamic value) { + upgradeDto(value, "JobStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_response_dto.dart b/mobile/openapi/lib/model/library_response_dto.dart index e27b48910439c..3cf12485080ac 100644 --- a/mobile/openapi/lib/model/library_response_dto.dart +++ b/mobile/openapi/lib/model/library_response_dto.dart @@ -92,6 +92,7 @@ class LibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart index 8cfb292855104..afe67da31a251 100644 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -58,6 +58,7 @@ class LibraryStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LibraryStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LibraryStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_key_dto.dart b/mobile/openapi/lib/model/license_key_dto.dart index aece85f81e9a0..d27d579bb4831 100644 --- a/mobile/openapi/lib/model/license_key_dto.dart +++ b/mobile/openapi/lib/model/license_key_dto.dart @@ -46,6 +46,7 @@ class LicenseKeyDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseKeyDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseKeyDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/license_response_dto.dart b/mobile/openapi/lib/model/license_response_dto.dart index f83668af575c9..6d3009433fd80 100644 --- a/mobile/openapi/lib/model/license_response_dto.dart +++ b/mobile/openapi/lib/model/license_response_dto.dart @@ -52,6 +52,7 @@ class LicenseResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LicenseResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LicenseResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_credential_dto.dart b/mobile/openapi/lib/model/login_credential_dto.dart index ac2f5116916f2..7e892ab5fbcb6 100644 --- a/mobile/openapi/lib/model/login_credential_dto.dart +++ b/mobile/openapi/lib/model/login_credential_dto.dart @@ -46,6 +46,7 @@ class LoginCredentialDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginCredentialDto? fromJson(dynamic value) { + upgradeDto(value, "LoginCredentialDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index 6a0eb2355ce55..dbc82d07ba1bb 100644 --- a/mobile/openapi/lib/model/login_response_dto.dart +++ b/mobile/openapi/lib/model/login_response_dto.dart @@ -76,6 +76,7 @@ class LoginResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LoginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LoginResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/logout_response_dto.dart b/mobile/openapi/lib/model/logout_response_dto.dart index ca1e8d23bbf32..aa94904e2a7df 100644 --- a/mobile/openapi/lib/model/logout_response_dto.dart +++ b/mobile/openapi/lib/model/logout_response_dto.dart @@ -46,6 +46,7 @@ class LogoutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static LogoutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "LogoutResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart new file mode 100644 index 0000000000000..7e8d9d51b2bab --- /dev/null +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class ManualJobName { + /// Instantiate a new enum with the provided [value]. + const ManualJobName._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const personCleanup = ManualJobName._(r'person-cleanup'); + static const tagCleanup = ManualJobName._(r'tag-cleanup'); + static const userCleanup = ManualJobName._(r'user-cleanup'); + + /// List of all possible values in this [enum][ManualJobName]. + static const values = [ + personCleanup, + tagCleanup, + userCleanup, + ]; + + static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ManualJobName.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [ManualJobName] to String, +/// and [decode] dynamic data back to [ManualJobName]. +class ManualJobNameTypeTransformer { + factory ManualJobNameTypeTransformer() => _instance ??= const ManualJobNameTypeTransformer._(); + + const ManualJobNameTypeTransformer._(); + + String encode(ManualJobName data) => data.value; + + /// Decodes a [dynamic value][data] to a ManualJobName. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + ManualJobName? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'person-cleanup': return ManualJobName.personCleanup; + case r'tag-cleanup': return ManualJobName.tagCleanup; + case r'user-cleanup': return ManualJobName.userCleanup; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [ManualJobNameTypeTransformer] instance. + static ManualJobNameTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/map_marker_response_dto.dart b/mobile/openapi/lib/model/map_marker_response_dto.dart index ca1ec3c8a1ced..74ac51a271478 100644 --- a/mobile/openapi/lib/model/map_marker_response_dto.dart +++ b/mobile/openapi/lib/model/map_marker_response_dto.dart @@ -82,6 +82,7 @@ class MapMarkerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapMarkerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapMarkerResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart index ac99dd91a9915..6d8757d39ff61 100644 --- a/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart +++ b/mobile/openapi/lib/model/map_reverse_geocode_response_dto.dart @@ -64,6 +64,7 @@ class MapReverseGeocodeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MapReverseGeocodeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MapReverseGeocodeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/map_theme.dart b/mobile/openapi/lib/model/map_theme.dart deleted file mode 100644 index e2553790c6cc1..0000000000000 --- a/mobile/openapi/lib/model/map_theme.dart +++ /dev/null @@ -1,85 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class MapTheme { - /// Instantiate a new enum with the provided [value]. - const MapTheme._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const light = MapTheme._(r'light'); - static const dark = MapTheme._(r'dark'); - - /// List of all possible values in this [enum][MapTheme]. - static const values = [ - light, - dark, - ]; - - static MapTheme? fromJson(dynamic value) => MapThemeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = MapTheme.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [MapTheme] to String, -/// and [decode] dynamic data back to [MapTheme]. -class MapThemeTypeTransformer { - factory MapThemeTypeTransformer() => _instance ??= const MapThemeTypeTransformer._(); - - const MapThemeTypeTransformer._(); - - String encode(MapTheme data) => data.value; - - /// Decodes a [dynamic value][data] to a MapTheme. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - MapTheme? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'light': return MapTheme.light; - case r'dark': return MapTheme.dark; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [MapThemeTypeTransformer] instance. - static MapThemeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart index e215a66a03f67..b9f8b5d8b1862 100644 --- a/mobile/openapi/lib/model/memories_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -40,6 +40,7 @@ class MemoriesResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesResponse? fromJson(dynamic value) { + upgradeDto(value, "MemoriesResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memories_update.dart b/mobile/openapi/lib/model/memories_update.dart index d30949136197e..71efd71ae7866 100644 --- a/mobile/openapi/lib/model/memories_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -50,6 +50,7 @@ class MemoriesUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoriesUpdate? fromJson(dynamic value) { + upgradeDto(value, "MemoriesUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_create_dto.dart b/mobile/openapi/lib/model/memory_create_dto.dart index 2efdf88936093..15985f2f1c175 100644 --- a/mobile/openapi/lib/model/memory_create_dto.dart +++ b/mobile/openapi/lib/model/memory_create_dto.dart @@ -90,6 +90,7 @@ class MemoryCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryCreateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_lane_response_dto.dart b/mobile/openapi/lib/model/memory_lane_response_dto.dart index 4abe607381f35..27248d05c1f64 100644 --- a/mobile/openapi/lib/model/memory_lane_response_dto.dart +++ b/mobile/openapi/lib/model/memory_lane_response_dto.dart @@ -46,6 +46,7 @@ class MemoryLaneResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryLaneResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryLaneResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_response_dto.dart b/mobile/openapi/lib/model/memory_response_dto.dart index f794be53cd5d3..652c993536aa4 100644 --- a/mobile/openapi/lib/model/memory_response_dto.dart +++ b/mobile/openapi/lib/model/memory_response_dto.dart @@ -120,6 +120,7 @@ class MemoryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/memory_update_dto.dart b/mobile/openapi/lib/model/memory_update_dto.dart index 318f4b42add00..e750f9faad330 100644 --- a/mobile/openapi/lib/model/memory_update_dto.dart +++ b/mobile/openapi/lib/model/memory_update_dto.dart @@ -82,6 +82,7 @@ class MemoryUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MemoryUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "MemoryUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_person_dto.dart index ea23042e2c5e0..fd225276b6f0a 100644 --- a/mobile/openapi/lib/model/merge_person_dto.dart +++ b/mobile/openapi/lib/model/merge_person_dto.dart @@ -40,6 +40,7 @@ class MergePersonDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MergePersonDto? fromJson(dynamic value) { + upgradeDto(value, "MergePersonDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index fabf7a26107ec..0aef1f623efd0 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -637,6 +637,7 @@ class MetadataSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static MetadataSearchDto? fromJson(dynamic value) { + upgradeDto(value, "MetadataSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart index ffd017f8168d6..869c3be753f70 100644 --- a/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart +++ b/mobile/openapi/lib/model/o_auth_authorize_response_dto.dart @@ -40,6 +40,7 @@ class OAuthAuthorizeResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthAuthorizeResponseDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthAuthorizeResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_callback_dto.dart b/mobile/openapi/lib/model/o_auth_callback_dto.dart index 89ad0f60b0b09..d0b98d5c6f503 100644 --- a/mobile/openapi/lib/model/o_auth_callback_dto.dart +++ b/mobile/openapi/lib/model/o_auth_callback_dto.dart @@ -40,6 +40,7 @@ class OAuthCallbackDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthCallbackDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthCallbackDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/o_auth_config_dto.dart b/mobile/openapi/lib/model/o_auth_config_dto.dart index 7d7675886497b..86c79b4e04ff6 100644 --- a/mobile/openapi/lib/model/o_auth_config_dto.dart +++ b/mobile/openapi/lib/model/o_auth_config_dto.dart @@ -40,6 +40,7 @@ class OAuthConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OAuthConfigDto? fromJson(dynamic value) { + upgradeDto(value, "OAuthConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/on_this_day_dto.dart b/mobile/openapi/lib/model/on_this_day_dto.dart index be170caf853a9..bfcc4fd630589 100644 --- a/mobile/openapi/lib/model/on_this_day_dto.dart +++ b/mobile/openapi/lib/model/on_this_day_dto.dart @@ -41,6 +41,7 @@ class OnThisDayDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static OnThisDayDto? fromJson(dynamic value) { + upgradeDto(value, "OnThisDayDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 7c3cf03bd9b44..f61df86b42bf4 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -18,6 +18,7 @@ class PartnerResponseDto { required this.id, this.inTimeline, required this.name, + required this.profileChangedAt, required this.profileImagePath, }); @@ -37,6 +38,8 @@ class PartnerResponseDto { String name; + DateTime profileChangedAt; + String profileImagePath; @override @@ -46,6 +49,7 @@ class PartnerResponseDto { other.id == id && other.inTimeline == inTimeline && other.name == name && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath; @override @@ -56,10 +60,11 @@ class PartnerResponseDto { (id.hashCode) + (inTimeline == null ? 0 : inTimeline!.hashCode) + (name.hashCode) + + (profileChangedAt.hashCode) + (profileImagePath.hashCode); @override - String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, email=$email, id=$id, inTimeline=$inTimeline, name=$name, profileImagePath=$profileImagePath]'; + String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, email=$email, id=$id, inTimeline=$inTimeline, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath]'; Map toJson() { final json = {}; @@ -72,6 +77,7 @@ class PartnerResponseDto { // json[r'inTimeline'] = null; } json[r'name'] = this.name; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; return json; } @@ -80,6 +86,7 @@ class PartnerResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PartnerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PartnerResponseDto"); if (value is Map) { final json = value.cast(); @@ -89,6 +96,7 @@ class PartnerResponseDto { id: mapValueOfType(json, r'id')!, inTimeline: mapValueOfType(json, r'inTimeline'), name: mapValueOfType(json, r'name')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, ); } @@ -141,6 +149,7 @@ class PartnerResponseDto { 'email', 'id', 'name', + 'profileChangedAt', 'profileImagePath', }; } diff --git a/mobile/openapi/lib/model/people_response.dart b/mobile/openapi/lib/model/people_response.dart index e12f86eeab5ba..1312c738744ef 100644 --- a/mobile/openapi/lib/model/people_response.dart +++ b/mobile/openapi/lib/model/people_response.dart @@ -46,6 +46,7 @@ class PeopleResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponse? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 87e8c34fb0d7d..49f0e85aad421 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -69,6 +69,7 @@ class PeopleResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update.dart b/mobile/openapi/lib/model/people_update.dart index 7803e6297036a..fb4eeeb434fda 100644 --- a/mobile/openapi/lib/model/people_update.dart +++ b/mobile/openapi/lib/model/people_update.dart @@ -66,6 +66,7 @@ class PeopleUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdate? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_dto.dart b/mobile/openapi/lib/model/people_update_dto.dart index 9fcfdc8761018..f771084f75c63 100644 --- a/mobile/openapi/lib/model/people_update_dto.dart +++ b/mobile/openapi/lib/model/people_update_dto.dart @@ -40,6 +40,7 @@ class PeopleUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 8af0a8b11ab8d..042e4fa36f969 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -103,6 +103,7 @@ class PeopleUpdateItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PeopleUpdateItem? fromJson(dynamic value) { + upgradeDto(value, "PeopleUpdateItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 9889328dee639..36bd6dfee9072 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -79,6 +79,7 @@ class PersonCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonCreateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 50ee28f0af5fe..0b36fcde3b271 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -85,6 +85,7 @@ class PersonResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart index 929fbc29d2585..d9f84e9f4c226 100644 --- a/mobile/openapi/lib/model/person_statistics_response_dto.dart +++ b/mobile/openapi/lib/model/person_statistics_response_dto.dart @@ -40,6 +40,7 @@ class PersonStatisticsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonStatisticsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonStatisticsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 1af03890a2aa3..51a7ea25d07b3 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -96,6 +96,7 @@ class PersonUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "PersonUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index af2e7101c3477..b14bad789505b 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -91,6 +91,7 @@ class PersonWithFacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PersonWithFacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PersonWithFacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/places_response_dto.dart b/mobile/openapi/lib/model/places_response_dto.dart index d3e1fc449bc5e..4f77788263450 100644 --- a/mobile/openapi/lib/model/places_response_dto.dart +++ b/mobile/openapi/lib/model/places_response_dto.dart @@ -84,6 +84,7 @@ class PlacesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PlacesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PlacesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_response.dart b/mobile/openapi/lib/model/purchase_response.dart index 284d8995289ec..a1172069771ea 100644 --- a/mobile/openapi/lib/model/purchase_response.dart +++ b/mobile/openapi/lib/model/purchase_response.dart @@ -46,6 +46,7 @@ class PurchaseResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseResponse? fromJson(dynamic value) { + upgradeDto(value, "PurchaseResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/purchase_update.dart b/mobile/openapi/lib/model/purchase_update.dart index ca0a27e3bc4ba..69057e6c55a46 100644 --- a/mobile/openapi/lib/model/purchase_update.dart +++ b/mobile/openapi/lib/model/purchase_update.dart @@ -66,6 +66,7 @@ class PurchaseUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static PurchaseUpdate? fromJson(dynamic value) { + upgradeDto(value, "PurchaseUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/queue_status_dto.dart b/mobile/openapi/lib/model/queue_status_dto.dart index 7f7d310f6ff07..77591affe2f3d 100644 --- a/mobile/openapi/lib/model/queue_status_dto.dart +++ b/mobile/openapi/lib/model/queue_status_dto.dart @@ -46,6 +46,7 @@ class QueueStatusDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static QueueStatusDto? fromJson(dynamic value) { + upgradeDto(value, "QueueStatusDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart new file mode 100644 index 0000000000000..3fcab05bbb275 --- /dev/null +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -0,0 +1,566 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class RandomSearchDto { + /// Returns a new [RandomSearchDto] instance. + RandomSearchDto({ + this.city, + this.country, + this.createdAfter, + this.createdBefore, + this.deviceId, + this.isArchived, + this.isEncoded, + this.isFavorite, + this.isMotion, + this.isNotInAlbum, + this.isOffline, + this.isVisible, + this.lensModel, + this.libraryId, + this.make, + this.model, + this.personIds = const [], + this.size, + this.state, + this.takenAfter, + this.takenBefore, + this.trashedAfter, + this.trashedBefore, + this.type, + this.updatedAfter, + this.updatedBefore, + this.withArchived = false, + this.withDeleted, + this.withExif, + this.withPeople, + this.withStacked, + }); + + String? city; + + String? country; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? createdBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? deviceId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isArchived; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isEncoded; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isMotion; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isNotInAlbum; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isOffline; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isVisible; + + String? lensModel; + + String? libraryId; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? make; + + String? model; + + List personIds; + + /// Minimum value: 1 + /// Maximum value: 1000 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? size; + + String? state; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? takenBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? trashedBefore; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetTypeEnum? type; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedAfter; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DateTime? updatedBefore; + + bool withArchived; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withDeleted; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withExif; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withPeople; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? withStacked; + + @override + bool operator ==(Object other) => identical(this, other) || other is RandomSearchDto && + other.city == city && + other.country == country && + other.createdAfter == createdAfter && + other.createdBefore == createdBefore && + other.deviceId == deviceId && + other.isArchived == isArchived && + other.isEncoded == isEncoded && + other.isFavorite == isFavorite && + other.isMotion == isMotion && + other.isNotInAlbum == isNotInAlbum && + other.isOffline == isOffline && + other.isVisible == isVisible && + other.lensModel == lensModel && + other.libraryId == libraryId && + other.make == make && + other.model == model && + _deepEquality.equals(other.personIds, personIds) && + other.size == size && + other.state == state && + other.takenAfter == takenAfter && + other.takenBefore == takenBefore && + other.trashedAfter == trashedAfter && + other.trashedBefore == trashedBefore && + other.type == type && + other.updatedAfter == updatedAfter && + other.updatedBefore == updatedBefore && + other.withArchived == withArchived && + other.withDeleted == withDeleted && + other.withExif == withExif && + other.withPeople == withPeople && + other.withStacked == withStacked; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (city == null ? 0 : city!.hashCode) + + (country == null ? 0 : country!.hashCode) + + (createdAfter == null ? 0 : createdAfter!.hashCode) + + (createdBefore == null ? 0 : createdBefore!.hashCode) + + (deviceId == null ? 0 : deviceId!.hashCode) + + (isArchived == null ? 0 : isArchived!.hashCode) + + (isEncoded == null ? 0 : isEncoded!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + + (isMotion == null ? 0 : isMotion!.hashCode) + + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + + (isOffline == null ? 0 : isOffline!.hashCode) + + (isVisible == null ? 0 : isVisible!.hashCode) + + (lensModel == null ? 0 : lensModel!.hashCode) + + (libraryId == null ? 0 : libraryId!.hashCode) + + (make == null ? 0 : make!.hashCode) + + (model == null ? 0 : model!.hashCode) + + (personIds.hashCode) + + (size == null ? 0 : size!.hashCode) + + (state == null ? 0 : state!.hashCode) + + (takenAfter == null ? 0 : takenAfter!.hashCode) + + (takenBefore == null ? 0 : takenBefore!.hashCode) + + (trashedAfter == null ? 0 : trashedAfter!.hashCode) + + (trashedBefore == null ? 0 : trashedBefore!.hashCode) + + (type == null ? 0 : type!.hashCode) + + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + + (withArchived.hashCode) + + (withDeleted == null ? 0 : withDeleted!.hashCode) + + (withExif == null ? 0 : withExif!.hashCode) + + (withPeople == null ? 0 : withPeople!.hashCode) + + (withStacked == null ? 0 : withStacked!.hashCode); + + @override + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, size=$size, state=$state, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + + Map toJson() { + final json = {}; + if (this.city != null) { + json[r'city'] = this.city; + } else { + // json[r'city'] = null; + } + if (this.country != null) { + json[r'country'] = this.country; + } else { + // json[r'country'] = null; + } + if (this.createdAfter != null) { + json[r'createdAfter'] = this.createdAfter!.toUtc().toIso8601String(); + } else { + // json[r'createdAfter'] = null; + } + if (this.createdBefore != null) { + json[r'createdBefore'] = this.createdBefore!.toUtc().toIso8601String(); + } else { + // json[r'createdBefore'] = null; + } + if (this.deviceId != null) { + json[r'deviceId'] = this.deviceId; + } else { + // json[r'deviceId'] = null; + } + if (this.isArchived != null) { + json[r'isArchived'] = this.isArchived; + } else { + // json[r'isArchived'] = null; + } + if (this.isEncoded != null) { + json[r'isEncoded'] = this.isEncoded; + } else { + // json[r'isEncoded'] = null; + } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } + if (this.isMotion != null) { + json[r'isMotion'] = this.isMotion; + } else { + // json[r'isMotion'] = null; + } + if (this.isNotInAlbum != null) { + json[r'isNotInAlbum'] = this.isNotInAlbum; + } else { + // json[r'isNotInAlbum'] = null; + } + if (this.isOffline != null) { + json[r'isOffline'] = this.isOffline; + } else { + // json[r'isOffline'] = null; + } + if (this.isVisible != null) { + json[r'isVisible'] = this.isVisible; + } else { + // json[r'isVisible'] = null; + } + if (this.lensModel != null) { + json[r'lensModel'] = this.lensModel; + } else { + // json[r'lensModel'] = null; + } + if (this.libraryId != null) { + json[r'libraryId'] = this.libraryId; + } else { + // json[r'libraryId'] = null; + } + if (this.make != null) { + json[r'make'] = this.make; + } else { + // json[r'make'] = null; + } + if (this.model != null) { + json[r'model'] = this.model; + } else { + // json[r'model'] = null; + } + json[r'personIds'] = this.personIds; + if (this.size != null) { + json[r'size'] = this.size; + } else { + // json[r'size'] = null; + } + if (this.state != null) { + json[r'state'] = this.state; + } else { + // json[r'state'] = null; + } + if (this.takenAfter != null) { + json[r'takenAfter'] = this.takenAfter!.toUtc().toIso8601String(); + } else { + // json[r'takenAfter'] = null; + } + if (this.takenBefore != null) { + json[r'takenBefore'] = this.takenBefore!.toUtc().toIso8601String(); + } else { + // json[r'takenBefore'] = null; + } + if (this.trashedAfter != null) { + json[r'trashedAfter'] = this.trashedAfter!.toUtc().toIso8601String(); + } else { + // json[r'trashedAfter'] = null; + } + if (this.trashedBefore != null) { + json[r'trashedBefore'] = this.trashedBefore!.toUtc().toIso8601String(); + } else { + // json[r'trashedBefore'] = null; + } + if (this.type != null) { + json[r'type'] = this.type; + } else { + // json[r'type'] = null; + } + if (this.updatedAfter != null) { + json[r'updatedAfter'] = this.updatedAfter!.toUtc().toIso8601String(); + } else { + // json[r'updatedAfter'] = null; + } + if (this.updatedBefore != null) { + json[r'updatedBefore'] = this.updatedBefore!.toUtc().toIso8601String(); + } else { + // json[r'updatedBefore'] = null; + } + json[r'withArchived'] = this.withArchived; + if (this.withDeleted != null) { + json[r'withDeleted'] = this.withDeleted; + } else { + // json[r'withDeleted'] = null; + } + if (this.withExif != null) { + json[r'withExif'] = this.withExif; + } else { + // json[r'withExif'] = null; + } + if (this.withPeople != null) { + json[r'withPeople'] = this.withPeople; + } else { + // json[r'withPeople'] = null; + } + if (this.withStacked != null) { + json[r'withStacked'] = this.withStacked; + } else { + // json[r'withStacked'] = null; + } + return json; + } + + /// Returns a new [RandomSearchDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static RandomSearchDto? fromJson(dynamic value) { + upgradeDto(value, "RandomSearchDto"); + if (value is Map) { + final json = value.cast(); + + return RandomSearchDto( + city: mapValueOfType(json, r'city'), + country: mapValueOfType(json, r'country'), + createdAfter: mapDateTime(json, r'createdAfter', r''), + createdBefore: mapDateTime(json, r'createdBefore', r''), + deviceId: mapValueOfType(json, r'deviceId'), + isArchived: mapValueOfType(json, r'isArchived'), + isEncoded: mapValueOfType(json, r'isEncoded'), + isFavorite: mapValueOfType(json, r'isFavorite'), + isMotion: mapValueOfType(json, r'isMotion'), + isNotInAlbum: mapValueOfType(json, r'isNotInAlbum'), + isOffline: mapValueOfType(json, r'isOffline'), + isVisible: mapValueOfType(json, r'isVisible'), + lensModel: mapValueOfType(json, r'lensModel'), + libraryId: mapValueOfType(json, r'libraryId'), + make: mapValueOfType(json, r'make'), + model: mapValueOfType(json, r'model'), + personIds: json[r'personIds'] is Iterable + ? (json[r'personIds'] as Iterable).cast().toList(growable: false) + : const [], + size: num.parse('${json[r'size']}'), + state: mapValueOfType(json, r'state'), + takenAfter: mapDateTime(json, r'takenAfter', r''), + takenBefore: mapDateTime(json, r'takenBefore', r''), + trashedAfter: mapDateTime(json, r'trashedAfter', r''), + trashedBefore: mapDateTime(json, r'trashedBefore', r''), + type: AssetTypeEnum.fromJson(json[r'type']), + updatedAfter: mapDateTime(json, r'updatedAfter', r''), + updatedBefore: mapDateTime(json, r'updatedBefore', r''), + withArchived: mapValueOfType(json, r'withArchived') ?? false, + withDeleted: mapValueOfType(json, r'withDeleted'), + withExif: mapValueOfType(json, r'withExif'), + withPeople: mapValueOfType(json, r'withPeople'), + withStacked: mapValueOfType(json, r'withStacked'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = RandomSearchDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = RandomSearchDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of RandomSearchDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = RandomSearchDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/ratings_response.dart b/mobile/openapi/lib/model/ratings_response.dart index c8791aa91a5ee..8e1951277ae80 100644 --- a/mobile/openapi/lib/model/ratings_response.dart +++ b/mobile/openapi/lib/model/ratings_response.dart @@ -40,6 +40,7 @@ class RatingsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsResponse? fromJson(dynamic value) { + upgradeDto(value, "RatingsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/ratings_update.dart b/mobile/openapi/lib/model/ratings_update.dart index bde51bad1b360..5d9f9a655f0ad 100644 --- a/mobile/openapi/lib/model/ratings_update.dart +++ b/mobile/openapi/lib/model/ratings_update.dart @@ -50,6 +50,7 @@ class RatingsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static RatingsUpdate? fromJson(dynamic value) { + upgradeDto(value, "RatingsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart index eb414be984015..5b3648b46bb2a 100644 --- a/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart +++ b/mobile/openapi/lib/model/reverse_geocoding_state_response_dto.dart @@ -54,6 +54,7 @@ class ReverseGeocodingStateResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ReverseGeocodingStateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ReverseGeocodingStateResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/scan_library_dto.dart b/mobile/openapi/lib/model/scan_library_dto.dart deleted file mode 100644 index 1b31aaaf01702..0000000000000 --- a/mobile/openapi/lib/model/scan_library_dto.dart +++ /dev/null @@ -1,124 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class ScanLibraryDto { - /// Returns a new [ScanLibraryDto] instance. - ScanLibraryDto({ - this.refreshAllFiles, - this.refreshModifiedFiles, - }); - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshAllFiles; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? refreshModifiedFiles; - - @override - bool operator ==(Object other) => identical(this, other) || other is ScanLibraryDto && - other.refreshAllFiles == refreshAllFiles && - other.refreshModifiedFiles == refreshModifiedFiles; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (refreshAllFiles == null ? 0 : refreshAllFiles!.hashCode) + - (refreshModifiedFiles == null ? 0 : refreshModifiedFiles!.hashCode); - - @override - String toString() => 'ScanLibraryDto[refreshAllFiles=$refreshAllFiles, refreshModifiedFiles=$refreshModifiedFiles]'; - - Map toJson() { - final json = {}; - if (this.refreshAllFiles != null) { - json[r'refreshAllFiles'] = this.refreshAllFiles; - } else { - // json[r'refreshAllFiles'] = null; - } - if (this.refreshModifiedFiles != null) { - json[r'refreshModifiedFiles'] = this.refreshModifiedFiles; - } else { - // json[r'refreshModifiedFiles'] = null; - } - return json; - } - - /// Returns a new [ScanLibraryDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static ScanLibraryDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return ScanLibraryDto( - refreshAllFiles: mapValueOfType(json, r'refreshAllFiles'), - refreshModifiedFiles: mapValueOfType(json, r'refreshModifiedFiles'), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = ScanLibraryDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = ScanLibraryDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of ScanLibraryDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = ScanLibraryDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - }; -} - diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart index 46ce5273ac947..e9b47e85ec9a3 100644 --- a/mobile/openapi/lib/model/search_album_response_dto.dart +++ b/mobile/openapi/lib/model/search_album_response_dto.dart @@ -58,6 +58,7 @@ class SearchAlbumResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAlbumResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAlbumResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart index 21ddbbb2132f2..3d214e61d9fef 100644 --- a/mobile/openapi/lib/model/search_asset_response_dto.dart +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -68,6 +68,7 @@ class SearchAssetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchAssetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAssetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart index 951fdd1bc8ce2..d44b2cd704a9a 100644 --- a/mobile/openapi/lib/model/search_explore_item.dart +++ b/mobile/openapi/lib/model/search_explore_item.dart @@ -46,6 +46,7 @@ class SearchExploreItem { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreItem? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreItem"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart index 5bc601de9e59b..3b5d4f984933a 100644 --- a/mobile/openapi/lib/model/search_explore_response_dto.dart +++ b/mobile/openapi/lib/model/search_explore_response_dto.dart @@ -46,6 +46,7 @@ class SearchExploreResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchExploreResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchExploreResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_count_response_dto.dart b/mobile/openapi/lib/model/search_facet_count_response_dto.dart index b40710e5251e4..f8eee844859e3 100644 --- a/mobile/openapi/lib/model/search_facet_count_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_count_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetCountResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetCountResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetCountResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart index 0784921c6b3e4..aeec873c8ddfb 100644 --- a/mobile/openapi/lib/model/search_facet_response_dto.dart +++ b/mobile/openapi/lib/model/search_facet_response_dto.dart @@ -46,6 +46,7 @@ class SearchFacetResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchFacetResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchFacetResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart index 9b2b7fd3cf75d..ca742ae35ccbc 100644 --- a/mobile/openapi/lib/model/search_response_dto.dart +++ b/mobile/openapi/lib/model/search_response_dto.dart @@ -46,6 +46,7 @@ class SearchResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SearchResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart index 9c71d1fccdcb4..5d53d5fdee665 100644 --- a/mobile/openapi/lib/model/server_about_response_dto.dart +++ b/mobile/openapi/lib/model/server_about_response_dto.dart @@ -28,6 +28,10 @@ class ServerAboutResponseDto { this.sourceCommit, this.sourceRef, this.sourceUrl, + this.thirdPartyBugFeatureUrl, + this.thirdPartyDocumentationUrl, + this.thirdPartySourceUrl, + this.thirdPartySupportUrl, required this.version, required this.versionUrl, }); @@ -146,6 +150,38 @@ class ServerAboutResponseDto { /// String? sourceUrl; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? thirdPartyBugFeatureUrl; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? thirdPartyDocumentationUrl; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? thirdPartySourceUrl; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? thirdPartySupportUrl; + String version; String versionUrl; @@ -167,6 +203,10 @@ class ServerAboutResponseDto { other.sourceCommit == sourceCommit && other.sourceRef == sourceRef && other.sourceUrl == sourceUrl && + other.thirdPartyBugFeatureUrl == thirdPartyBugFeatureUrl && + other.thirdPartyDocumentationUrl == thirdPartyDocumentationUrl && + other.thirdPartySourceUrl == thirdPartySourceUrl && + other.thirdPartySupportUrl == thirdPartySupportUrl && other.version == version && other.versionUrl == versionUrl; @@ -188,11 +228,15 @@ class ServerAboutResponseDto { (sourceCommit == null ? 0 : sourceCommit!.hashCode) + (sourceRef == null ? 0 : sourceRef!.hashCode) + (sourceUrl == null ? 0 : sourceUrl!.hashCode) + + (thirdPartyBugFeatureUrl == null ? 0 : thirdPartyBugFeatureUrl!.hashCode) + + (thirdPartyDocumentationUrl == null ? 0 : thirdPartyDocumentationUrl!.hashCode) + + (thirdPartySourceUrl == null ? 0 : thirdPartySourceUrl!.hashCode) + + (thirdPartySupportUrl == null ? 0 : thirdPartySupportUrl!.hashCode) + (version.hashCode) + (versionUrl.hashCode); @override - String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, licensed=$licensed, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, version=$version, versionUrl=$versionUrl]'; + String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, licensed=$licensed, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, thirdPartyBugFeatureUrl=$thirdPartyBugFeatureUrl, thirdPartyDocumentationUrl=$thirdPartyDocumentationUrl, thirdPartySourceUrl=$thirdPartySourceUrl, thirdPartySupportUrl=$thirdPartySupportUrl, version=$version, versionUrl=$versionUrl]'; Map toJson() { final json = {}; @@ -266,6 +310,26 @@ class ServerAboutResponseDto { json[r'sourceUrl'] = this.sourceUrl; } else { // json[r'sourceUrl'] = null; + } + if (this.thirdPartyBugFeatureUrl != null) { + json[r'thirdPartyBugFeatureUrl'] = this.thirdPartyBugFeatureUrl; + } else { + // json[r'thirdPartyBugFeatureUrl'] = null; + } + if (this.thirdPartyDocumentationUrl != null) { + json[r'thirdPartyDocumentationUrl'] = this.thirdPartyDocumentationUrl; + } else { + // json[r'thirdPartyDocumentationUrl'] = null; + } + if (this.thirdPartySourceUrl != null) { + json[r'thirdPartySourceUrl'] = this.thirdPartySourceUrl; + } else { + // json[r'thirdPartySourceUrl'] = null; + } + if (this.thirdPartySupportUrl != null) { + json[r'thirdPartySupportUrl'] = this.thirdPartySupportUrl; + } else { + // json[r'thirdPartySupportUrl'] = null; } json[r'version'] = this.version; json[r'versionUrl'] = this.versionUrl; @@ -276,6 +340,7 @@ class ServerAboutResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerAboutResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerAboutResponseDto"); if (value is Map) { final json = value.cast(); @@ -295,6 +360,10 @@ class ServerAboutResponseDto { sourceCommit: mapValueOfType(json, r'sourceCommit'), sourceRef: mapValueOfType(json, r'sourceRef'), sourceUrl: mapValueOfType(json, r'sourceUrl'), + thirdPartyBugFeatureUrl: mapValueOfType(json, r'thirdPartyBugFeatureUrl'), + thirdPartyDocumentationUrl: mapValueOfType(json, r'thirdPartyDocumentationUrl'), + thirdPartySourceUrl: mapValueOfType(json, r'thirdPartySourceUrl'), + thirdPartySupportUrl: mapValueOfType(json, r'thirdPartySupportUrl'), version: mapValueOfType(json, r'version')!, versionUrl: mapValueOfType(json, r'versionUrl')!, ); diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 47cc52fb2c3fe..bd5c2405e29d3 100644 --- a/mobile/openapi/lib/model/server_config_dto.dart +++ b/mobile/openapi/lib/model/server_config_dto.dart @@ -17,6 +17,8 @@ class ServerConfigDto { required this.isInitialized, required this.isOnboarded, required this.loginPageMessage, + required this.mapDarkStyleUrl, + required this.mapLightStyleUrl, required this.oauthButtonText, required this.trashDays, required this.userDeleteDelay, @@ -30,6 +32,10 @@ class ServerConfigDto { String loginPageMessage; + String mapDarkStyleUrl; + + String mapLightStyleUrl; + String oauthButtonText; int trashDays; @@ -42,6 +48,8 @@ class ServerConfigDto { other.isInitialized == isInitialized && other.isOnboarded == isOnboarded && other.loginPageMessage == loginPageMessage && + other.mapDarkStyleUrl == mapDarkStyleUrl && + other.mapLightStyleUrl == mapLightStyleUrl && other.oauthButtonText == oauthButtonText && other.trashDays == trashDays && other.userDeleteDelay == userDeleteDelay; @@ -53,12 +61,14 @@ class ServerConfigDto { (isInitialized.hashCode) + (isOnboarded.hashCode) + (loginPageMessage.hashCode) + + (mapDarkStyleUrl.hashCode) + + (mapLightStyleUrl.hashCode) + (oauthButtonText.hashCode) + (trashDays.hashCode) + (userDeleteDelay.hashCode); @override - String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; + String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]'; Map toJson() { final json = {}; @@ -66,6 +76,8 @@ class ServerConfigDto { json[r'isInitialized'] = this.isInitialized; json[r'isOnboarded'] = this.isOnboarded; json[r'loginPageMessage'] = this.loginPageMessage; + json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl; + json[r'mapLightStyleUrl'] = this.mapLightStyleUrl; json[r'oauthButtonText'] = this.oauthButtonText; json[r'trashDays'] = this.trashDays; json[r'userDeleteDelay'] = this.userDeleteDelay; @@ -76,6 +88,7 @@ class ServerConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerConfigDto? fromJson(dynamic value) { + upgradeDto(value, "ServerConfigDto"); if (value is Map) { final json = value.cast(); @@ -84,6 +97,8 @@ class ServerConfigDto { isInitialized: mapValueOfType(json, r'isInitialized')!, isOnboarded: mapValueOfType(json, r'isOnboarded')!, loginPageMessage: mapValueOfType(json, r'loginPageMessage')!, + mapDarkStyleUrl: mapValueOfType(json, r'mapDarkStyleUrl')!, + mapLightStyleUrl: mapValueOfType(json, r'mapLightStyleUrl')!, oauthButtonText: mapValueOfType(json, r'oauthButtonText')!, trashDays: mapValueOfType(json, r'trashDays')!, userDeleteDelay: mapValueOfType(json, r'userDeleteDelay')!, @@ -138,6 +153,8 @@ class ServerConfigDto { 'isInitialized', 'isOnboarded', 'loginPageMessage', + 'mapDarkStyleUrl', + 'mapLightStyleUrl', 'oauthButtonText', 'trashDays', 'userDeleteDelay', diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 0a7d8a4b4774a..5149c3796a9da 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -118,6 +118,7 @@ class ServerFeaturesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerFeaturesDto? fromJson(dynamic value) { + upgradeDto(value, "ServerFeaturesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_media_types_response_dto.dart b/mobile/openapi/lib/model/server_media_types_response_dto.dart index 35ddef195601a..506cbb44b4d76 100644 --- a/mobile/openapi/lib/model/server_media_types_response_dto.dart +++ b/mobile/openapi/lib/model/server_media_types_response_dto.dart @@ -52,6 +52,7 @@ class ServerMediaTypesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerMediaTypesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerMediaTypesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_ping_response.dart b/mobile/openapi/lib/model/server_ping_response.dart index e23dc15c61af1..621ebfa2945ad 100644 --- a/mobile/openapi/lib/model/server_ping_response.dart +++ b/mobile/openapi/lib/model/server_ping_response.dart @@ -40,6 +40,7 @@ class ServerPingResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerPingResponse? fromJson(dynamic value) { + upgradeDto(value, "ServerPingResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_stats_response_dto.dart b/mobile/openapi/lib/model/server_stats_response_dto.dart index 6996e49aa5e24..654a34ee6b0e7 100644 --- a/mobile/openapi/lib/model/server_stats_response_dto.dart +++ b/mobile/openapi/lib/model/server_stats_response_dto.dart @@ -58,6 +58,7 @@ class ServerStatsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStatsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStatsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_storage_response_dto.dart b/mobile/openapi/lib/model/server_storage_response_dto.dart index 89d97d32ead2d..8d12e77834bec 100644 --- a/mobile/openapi/lib/model/server_storage_response_dto.dart +++ b/mobile/openapi/lib/model/server_storage_response_dto.dart @@ -76,6 +76,7 @@ class ServerStorageResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerStorageResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerStorageResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_theme_dto.dart b/mobile/openapi/lib/model/server_theme_dto.dart index 65b9b9163e874..69e1b2d2c87a7 100644 --- a/mobile/openapi/lib/model/server_theme_dto.dart +++ b/mobile/openapi/lib/model/server_theme_dto.dart @@ -40,6 +40,7 @@ class ServerThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerThemeDto? fromJson(dynamic value) { + upgradeDto(value, "ServerThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/server_version_history_response_dto.dart b/mobile/openapi/lib/model/server_version_history_response_dto.dart new file mode 100644 index 0000000000000..c81cb0e8b9f8b --- /dev/null +++ b/mobile/openapi/lib/model/server_version_history_response_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class ServerVersionHistoryResponseDto { + /// Returns a new [ServerVersionHistoryResponseDto] instance. + ServerVersionHistoryResponseDto({ + required this.createdAt, + required this.id, + required this.version, + }); + + DateTime createdAt; + + String id; + + String version; + + @override + bool operator ==(Object other) => identical(this, other) || other is ServerVersionHistoryResponseDto && + other.createdAt == createdAt && + other.id == id && + other.version == version; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (id.hashCode) + + (version.hashCode); + + @override + String toString() => 'ServerVersionHistoryResponseDto[createdAt=$createdAt, id=$id, version=$version]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'id'] = this.id; + json[r'version'] = this.version; + return json; + } + + /// Returns a new [ServerVersionHistoryResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ServerVersionHistoryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerVersionHistoryResponseDto"); + if (value is Map) { + final json = value.cast(); + + return ServerVersionHistoryResponseDto( + createdAt: mapDateTime(json, r'createdAt', r'')!, + id: mapValueOfType(json, r'id')!, + version: mapValueOfType(json, r'version')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ServerVersionHistoryResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = ServerVersionHistoryResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ServerVersionHistoryResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ServerVersionHistoryResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'id', + 'version', + }; +} + diff --git a/mobile/openapi/lib/model/server_version_response_dto.dart b/mobile/openapi/lib/model/server_version_response_dto.dart index e507f3372abfd..751347fabd2c1 100644 --- a/mobile/openapi/lib/model/server_version_response_dto.dart +++ b/mobile/openapi/lib/model/server_version_response_dto.dart @@ -52,6 +52,7 @@ class ServerVersionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ServerVersionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ServerVersionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 82673b3874b67..92e2dc60676af 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -70,6 +70,7 @@ class SessionResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SessionResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SessionResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 623bc3125fd48..bc96b31fd24f9 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -132,6 +132,7 @@ class SharedLinkCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkCreateDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 2369c85db1a12..a394ba9b3b8fd 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -141,6 +141,7 @@ class SharedLinkEditDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkEditDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkEditDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 018a1a51de2a4..9cc8b3ac80add 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -144,6 +144,7 @@ class SharedLinkResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SharedLinkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SharedLinkResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/sign_up_dto.dart b/mobile/openapi/lib/model/sign_up_dto.dart index 772749fdba48c..7e0ff4045c7b5 100644 --- a/mobile/openapi/lib/model/sign_up_dto.dart +++ b/mobile/openapi/lib/model/sign_up_dto.dart @@ -52,6 +52,7 @@ class SignUpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SignUpDto? fromJson(dynamic value) { + upgradeDto(value, "SignUpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_info_response_dto.dart b/mobile/openapi/lib/model/smart_info_response_dto.dart index 52e7c108b8291..4631eccf2cb1c 100644 --- a/mobile/openapi/lib/model/smart_info_response_dto.dart +++ b/mobile/openapi/lib/model/smart_info_response_dto.dart @@ -54,6 +54,7 @@ class SmartInfoResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartInfoResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SmartInfoResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 2a42b75768420..4e1408cafa737 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -467,6 +467,7 @@ class SmartSearchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SmartSearchDto? fromJson(dynamic value) { + upgradeDto(value, "SmartSearchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_create_dto.dart b/mobile/openapi/lib/model/stack_create_dto.dart index 9b37bc6e2e9aa..cb51081eb1ee4 100644 --- a/mobile/openapi/lib/model/stack_create_dto.dart +++ b/mobile/openapi/lib/model/stack_create_dto.dart @@ -41,6 +41,7 @@ class StackCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackCreateDto? fromJson(dynamic value) { + upgradeDto(value, "StackCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_response_dto.dart b/mobile/openapi/lib/model/stack_response_dto.dart index 3d0aaf91d17cc..b6cb747cafc5f 100644 --- a/mobile/openapi/lib/model/stack_response_dto.dart +++ b/mobile/openapi/lib/model/stack_response_dto.dart @@ -52,6 +52,7 @@ class StackResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "StackResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/stack_update_dto.dart b/mobile/openapi/lib/model/stack_update_dto.dart index 0e9712721048a..0101499edfc51 100644 --- a/mobile/openapi/lib/model/stack_update_dto.dart +++ b/mobile/openapi/lib/model/stack_update_dto.dart @@ -50,6 +50,7 @@ class StackUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static StackUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "StackUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index aff8062c8a139..5306370d2d1f7 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -142,6 +142,7 @@ class SystemConfigDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index a75a77c669168..73f7d35aecc30 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart @@ -175,6 +175,7 @@ class SystemConfigFFmpegDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFFmpegDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFFmpegDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_faces_dto.dart b/mobile/openapi/lib/model/system_config_faces_dto.dart index 980e494fb70b0..4e18eb8de20e4 100644 --- a/mobile/openapi/lib/model/system_config_faces_dto.dart +++ b/mobile/openapi/lib/model/system_config_faces_dto.dart @@ -40,6 +40,7 @@ class SystemConfigFacesDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigFacesDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigFacesDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart new file mode 100644 index 0000000000000..2192a7cb0cbd5 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_generated_image_dto.dart @@ -0,0 +1,118 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigGeneratedImageDto { + /// Returns a new [SystemConfigGeneratedImageDto] instance. + SystemConfigGeneratedImageDto({ + required this.format, + required this.quality, + required this.size, + }); + + ImageFormat format; + + /// Minimum value: 1 + /// Maximum value: 100 + int quality; + + /// Minimum value: 1 + int size; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto && + other.format == format && + other.quality == quality && + other.size == size; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (format.hashCode) + + (quality.hashCode) + + (size.hashCode); + + @override + String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]'; + + Map toJson() { + final json = {}; + json[r'format'] = this.format; + json[r'quality'] = this.quality; + json[r'size'] = this.size; + return json; + } + + /// Returns a new [SystemConfigGeneratedImageDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigGeneratedImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigGeneratedImageDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigGeneratedImageDto( + format: ImageFormat.fromJson(json[r'format'])!, + quality: mapValueOfType(json, r'quality')!, + size: mapValueOfType(json, r'size')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigGeneratedImageDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigGeneratedImageDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigGeneratedImageDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigGeneratedImageDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'format', + 'quality', + 'size', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 388949c759ce0..5309f7745c44d 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -15,64 +15,42 @@ class SystemConfigImageDto { SystemConfigImageDto({ required this.colorspace, required this.extractEmbedded, - required this.previewFormat, - required this.previewSize, - required this.quality, - required this.thumbnailFormat, - required this.thumbnailSize, + required this.preview, + required this.thumbnail, }); Colorspace colorspace; bool extractEmbedded; - ImageFormat previewFormat; + SystemConfigGeneratedImageDto preview; - /// Minimum value: 1 - int previewSize; - - /// Minimum value: 1 - /// Maximum value: 100 - int quality; - - ImageFormat thumbnailFormat; - - /// Minimum value: 1 - int thumbnailSize; + SystemConfigGeneratedImageDto thumbnail; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && other.colorspace == colorspace && other.extractEmbedded == extractEmbedded && - other.previewFormat == previewFormat && - other.previewSize == previewSize && - other.quality == quality && - other.thumbnailFormat == thumbnailFormat && - other.thumbnailSize == thumbnailSize; + other.preview == preview && + other.thumbnail == thumbnail; @override int get hashCode => // ignore: unnecessary_parenthesis (colorspace.hashCode) + (extractEmbedded.hashCode) + - (previewFormat.hashCode) + - (previewSize.hashCode) + - (quality.hashCode) + - (thumbnailFormat.hashCode) + - (thumbnailSize.hashCode); + (preview.hashCode) + + (thumbnail.hashCode); @override - String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]'; + String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'colorspace'] = this.colorspace; json[r'extractEmbedded'] = this.extractEmbedded; - json[r'previewFormat'] = this.previewFormat; - json[r'previewSize'] = this.previewSize; - json[r'quality'] = this.quality; - json[r'thumbnailFormat'] = this.thumbnailFormat; - json[r'thumbnailSize'] = this.thumbnailSize; + json[r'preview'] = this.preview; + json[r'thumbnail'] = this.thumbnail; return json; } @@ -80,17 +58,15 @@ class SystemConfigImageDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigImageDto"); if (value is Map) { final json = value.cast(); return SystemConfigImageDto( colorspace: Colorspace.fromJson(json[r'colorspace'])!, extractEmbedded: mapValueOfType(json, r'extractEmbedded')!, - previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!, - previewSize: mapValueOfType(json, r'previewSize')!, - quality: mapValueOfType(json, r'quality')!, - thumbnailFormat: ImageFormat.fromJson(json[r'thumbnailFormat'])!, - thumbnailSize: mapValueOfType(json, r'thumbnailSize')!, + preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!, + thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!, ); } return null; @@ -140,11 +116,8 @@ class SystemConfigImageDto { static const requiredKeys = { 'colorspace', 'extractEmbedded', - 'previewFormat', - 'previewSize', - 'quality', - 'thumbnailFormat', - 'thumbnailSize', + 'preview', + 'thumbnail', }; } diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 1bc0f6b29c1c0..c0fed5cccc06f 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -100,6 +100,7 @@ class SystemConfigJobDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigJobDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigJobDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_dto.dart b/mobile/openapi/lib/model/system_config_library_dto.dart index 4f55e33e8087b..e728b0bf20957 100644 --- a/mobile/openapi/lib/model/system_config_library_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_scan_dto.dart b/mobile/openapi/lib/model/system_config_library_scan_dto.dart index 31df272594577..6a6558b4b32ee 100644 --- a/mobile/openapi/lib/model/system_config_library_scan_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_scan_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLibraryScanDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryScanDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryScanDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_library_watch_dto.dart b/mobile/openapi/lib/model/system_config_library_watch_dto.dart index 9d152f366a898..1a1f5d7126b34 100644 --- a/mobile/openapi/lib/model/system_config_library_watch_dto.dart +++ b/mobile/openapi/lib/model/system_config_library_watch_dto.dart @@ -40,6 +40,7 @@ class SystemConfigLibraryWatchDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLibraryWatchDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLibraryWatchDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_logging_dto.dart b/mobile/openapi/lib/model/system_config_logging_dto.dart index 60c0be3d2c2ff..f025221eff996 100644 --- a/mobile/openapi/lib/model/system_config_logging_dto.dart +++ b/mobile/openapi/lib/model/system_config_logging_dto.dart @@ -46,6 +46,7 @@ class SystemConfigLoggingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigLoggingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigLoggingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart index 3923bacad4211..d665f0bfa56a7 100644 --- a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -64,6 +64,7 @@ class SystemConfigMachineLearningDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMachineLearningDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMachineLearningDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_map_dto.dart b/mobile/openapi/lib/model/system_config_map_dto.dart index 663188518275a..d53d5711db2af 100644 --- a/mobile/openapi/lib/model/system_config_map_dto.dart +++ b/mobile/openapi/lib/model/system_config_map_dto.dart @@ -52,6 +52,7 @@ class SystemConfigMapDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMapDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMapDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_metadata_dto.dart b/mobile/openapi/lib/model/system_config_metadata_dto.dart index 60ca35c835bdb..3c32fc551d4e2 100644 --- a/mobile/openapi/lib/model/system_config_metadata_dto.dart +++ b/mobile/openapi/lib/model/system_config_metadata_dto.dart @@ -40,6 +40,7 @@ class SystemConfigMetadataDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigMetadataDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigMetadataDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart index c7b8c98695c32..c63d2abc1ba30 100644 --- a/mobile/openapi/lib/model/system_config_new_version_check_dto.dart +++ b/mobile/openapi/lib/model/system_config_new_version_check_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNewVersionCheckDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNewVersionCheckDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNewVersionCheckDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_notifications_dto.dart b/mobile/openapi/lib/model/system_config_notifications_dto.dart index 22f08b3ab4365..35d3d318339e8 100644 --- a/mobile/openapi/lib/model/system_config_notifications_dto.dart +++ b/mobile/openapi/lib/model/system_config_notifications_dto.dart @@ -40,6 +40,7 @@ class SystemConfigNotificationsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigNotificationsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigNotificationsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index 6ebbe8d25c05c..9125bb7bba65a 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -125,6 +125,7 @@ class SystemConfigOAuthDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigOAuthDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigOAuthDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_password_login_dto.dart b/mobile/openapi/lib/model/system_config_password_login_dto.dart index 61896a890c58e..69c8942bb6471 100644 --- a/mobile/openapi/lib/model/system_config_password_login_dto.dart +++ b/mobile/openapi/lib/model/system_config_password_login_dto.dart @@ -40,6 +40,7 @@ class SystemConfigPasswordLoginDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigPasswordLoginDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigPasswordLoginDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart index 2eb586cac689c..6c1673d46c07d 100644 --- a/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart +++ b/mobile/openapi/lib/model/system_config_reverse_geocoding_dto.dart @@ -40,6 +40,7 @@ class SystemConfigReverseGeocodingDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigReverseGeocodingDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigReverseGeocodingDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_server_dto.dart b/mobile/openapi/lib/model/system_config_server_dto.dart index ccb48ee61dedb..b1b92c9515d5b 100644 --- a/mobile/openapi/lib/model/system_config_server_dto.dart +++ b/mobile/openapi/lib/model/system_config_server_dto.dart @@ -46,6 +46,7 @@ class SystemConfigServerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigServerDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigServerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_dto.dart b/mobile/openapi/lib/model/system_config_smtp_dto.dart index 6588d244ee5d1..fcde49cf3564e 100644 --- a/mobile/openapi/lib/model/system_config_smtp_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_dto.dart @@ -58,6 +58,7 @@ class SystemConfigSmtpDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart index 63dfdca4cf07e..bdaaa426c5220 100644 --- a/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart +++ b/mobile/openapi/lib/model/system_config_smtp_transport_dto.dart @@ -66,6 +66,7 @@ class SystemConfigSmtpTransportDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigSmtpTransportDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigSmtpTransportDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_storage_template_dto.dart b/mobile/openapi/lib/model/system_config_storage_template_dto.dart index 13323aebdaba3..596aafc1950a1 100644 --- a/mobile/openapi/lib/model/system_config_storage_template_dto.dart +++ b/mobile/openapi/lib/model/system_config_storage_template_dto.dart @@ -52,6 +52,7 @@ class SystemConfigStorageTemplateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigStorageTemplateDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigStorageTemplateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart index 82e0a6f74769b..f8586d344c5aa 100644 --- a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart +++ b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart @@ -82,6 +82,7 @@ class SystemConfigTemplateStorageOptionDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTemplateStorageOptionDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTemplateStorageOptionDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_theme_dto.dart b/mobile/openapi/lib/model/system_config_theme_dto.dart index 2f7f4d2f3b916..a97c2cf84c1f3 100644 --- a/mobile/openapi/lib/model/system_config_theme_dto.dart +++ b/mobile/openapi/lib/model/system_config_theme_dto.dart @@ -40,6 +40,7 @@ class SystemConfigThemeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigThemeDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigThemeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_trash_dto.dart b/mobile/openapi/lib/model/system_config_trash_dto.dart index 336019fde4203..51b39e9a55c1f 100644 --- a/mobile/openapi/lib/model/system_config_trash_dto.dart +++ b/mobile/openapi/lib/model/system_config_trash_dto.dart @@ -47,6 +47,7 @@ class SystemConfigTrashDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigTrashDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigTrashDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/system_config_user_dto.dart b/mobile/openapi/lib/model/system_config_user_dto.dart index c46637446098f..8e6bd3c9c306e 100644 --- a/mobile/openapi/lib/model/system_config_user_dto.dart +++ b/mobile/openapi/lib/model/system_config_user_dto.dart @@ -41,6 +41,7 @@ class SystemConfigUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static SystemConfigUserDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart index c11cb66ce081f..26a575e193dc6 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -46,6 +46,7 @@ class TagBulkAssetsDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart index d4dcb91d8c45d..009f26bfe4f49 100644 --- a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart @@ -40,6 +40,7 @@ class TagBulkAssetsResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagBulkAssetsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagBulkAssetsResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_create_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart index dd7e537a0a021..9a5171074d622 100644 --- a/mobile/openapi/lib/model/tag_create_dto.dart +++ b/mobile/openapi/lib/model/tag_create_dto.dart @@ -66,6 +66,7 @@ class TagCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagCreateDto? fromJson(dynamic value) { + upgradeDto(value, "TagCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index 1d1a88c3cff29..cd684b163a27d 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -96,6 +96,7 @@ class TagResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TagResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_update_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart index 661f65896e56f..ab1adb127bacb 100644 --- a/mobile/openapi/lib/model/tag_update_dto.dart +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -44,6 +44,7 @@ class TagUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart index 941d25b6aee6c..d60a00f466e1f 100644 --- a/mobile/openapi/lib/model/tag_upsert_dto.dart +++ b/mobile/openapi/lib/model/tag_upsert_dto.dart @@ -40,6 +40,7 @@ class TagUpsertDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "TagUpsertDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_response.dart b/mobile/openapi/lib/model/tags_response.dart index 3a5ea3b20b3ec..2470edf979239 100644 --- a/mobile/openapi/lib/model/tags_response.dart +++ b/mobile/openapi/lib/model/tags_response.dart @@ -46,6 +46,7 @@ class TagsResponse { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsResponse? fromJson(dynamic value) { + upgradeDto(value, "TagsResponse"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/tags_update.dart b/mobile/openapi/lib/model/tags_update.dart index 8355b00a00d49..d99236914055c 100644 --- a/mobile/openapi/lib/model/tags_update.dart +++ b/mobile/openapi/lib/model/tags_update.dart @@ -66,6 +66,7 @@ class TagsUpdate { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TagsUpdate? fromJson(dynamic value) { + upgradeDto(value, "TagsUpdate"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/test_email_response_dto.dart b/mobile/openapi/lib/model/test_email_response_dto.dart new file mode 100644 index 0000000000000..33e6c042d8d09 --- /dev/null +++ b/mobile/openapi/lib/model/test_email_response_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TestEmailResponseDto { + /// Returns a new [TestEmailResponseDto] instance. + TestEmailResponseDto({ + required this.messageId, + }); + + String messageId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TestEmailResponseDto && + other.messageId == messageId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (messageId.hashCode); + + @override + String toString() => 'TestEmailResponseDto[messageId=$messageId]'; + + Map toJson() { + final json = {}; + json[r'messageId'] = this.messageId; + return json; + } + + /// Returns a new [TestEmailResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TestEmailResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TestEmailResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TestEmailResponseDto( + messageId: mapValueOfType(json, r'messageId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TestEmailResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TestEmailResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TestEmailResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TestEmailResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'messageId', + }; +} + diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_bucket_response_dto.dart index 2c86a56b3c9ad..56044b27a8a81 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_response_dto.dart @@ -46,6 +46,7 @@ class TimeBucketResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static TimeBucketResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/trash_response_dto.dart b/mobile/openapi/lib/model/trash_response_dto.dart new file mode 100644 index 0000000000000..2df154d06c1a0 --- /dev/null +++ b/mobile/openapi/lib/model/trash_response_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TrashResponseDto { + /// Returns a new [TrashResponseDto] instance. + TrashResponseDto({ + required this.count, + }); + + int count; + + @override + bool operator ==(Object other) => identical(this, other) || other is TrashResponseDto && + other.count == count; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode); + + @override + String toString() => 'TrashResponseDto[count=$count]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + return json; + } + + /// Returns a new [TrashResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TrashResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TrashResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TrashResponseDto( + count: mapValueOfType(json, r'count')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TrashResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TrashResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TrashResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TrashResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + }; +} + diff --git a/mobile/openapi/lib/model/update_album_dto.dart b/mobile/openapi/lib/model/update_album_dto.dart index f9c9762887265..8353dba14e627 100644 --- a/mobile/openapi/lib/model/update_album_dto.dart +++ b/mobile/openapi/lib/model/update_album_dto.dart @@ -114,6 +114,7 @@ class UpdateAlbumDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_album_user_dto.dart b/mobile/openapi/lib/model/update_album_user_dto.dart index f77223acf5855..43218cae6e140 100644 --- a/mobile/openapi/lib/model/update_album_user_dto.dart +++ b/mobile/openapi/lib/model/update_album_user_dto.dart @@ -40,6 +40,7 @@ class UpdateAlbumUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAlbumUserDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAlbumUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index 391836c444bb3..9ebce5fd9232b 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -18,6 +18,7 @@ class UpdateAssetDto { this.isArchived, this.isFavorite, this.latitude, + this.livePhotoVideoId, this.longitude, this.rating, }); @@ -62,6 +63,8 @@ class UpdateAssetDto { /// num? latitude; + String? livePhotoVideoId; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -87,6 +90,7 @@ class UpdateAssetDto { other.isArchived == isArchived && other.isFavorite == isFavorite && other.latitude == latitude && + other.livePhotoVideoId == livePhotoVideoId && other.longitude == longitude && other.rating == rating; @@ -98,11 +102,12 @@ class UpdateAssetDto { (isArchived == null ? 0 : isArchived!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + (rating == null ? 0 : rating!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; + String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]'; Map toJson() { final json = {}; @@ -131,6 +136,11 @@ class UpdateAssetDto { } else { // json[r'latitude'] = null; } + if (this.livePhotoVideoId != null) { + json[r'livePhotoVideoId'] = this.livePhotoVideoId; + } else { + // json[r'livePhotoVideoId'] = null; + } if (this.longitude != null) { json[r'longitude'] = this.longitude; } else { @@ -148,6 +158,7 @@ class UpdateAssetDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateAssetDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateAssetDto"); if (value is Map) { final json = value.cast(); @@ -157,6 +168,7 @@ class UpdateAssetDto { isArchived: mapValueOfType(json, r'isArchived'), isFavorite: mapValueOfType(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), + livePhotoVideoId: mapValueOfType(json, r'livePhotoVideoId'), longitude: num.parse('${json[r'longitude']}'), rating: num.parse('${json[r'rating']}'), ); diff --git a/mobile/openapi/lib/model/update_library_dto.dart b/mobile/openapi/lib/model/update_library_dto.dart index 85847c0ddfb6f..b85df40172e69 100644 --- a/mobile/openapi/lib/model/update_library_dto.dart +++ b/mobile/openapi/lib/model/update_library_dto.dart @@ -62,6 +62,7 @@ class UpdateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/update_partner_dto.dart b/mobile/openapi/lib/model/update_partner_dto.dart index f695f99535a38..3af3c83ad10bd 100644 --- a/mobile/openapi/lib/model/update_partner_dto.dart +++ b/mobile/openapi/lib/model/update_partner_dto.dart @@ -40,6 +40,7 @@ class UpdatePartnerDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UpdatePartnerDto? fromJson(dynamic value) { + upgradeDto(value, "UpdatePartnerDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/usage_by_user_dto.dart b/mobile/openapi/lib/model/usage_by_user_dto.dart index 0bbbba00bbaab..e6f9216d74572 100644 --- a/mobile/openapi/lib/model/usage_by_user_dto.dart +++ b/mobile/openapi/lib/model/usage_by_user_dto.dart @@ -74,6 +74,7 @@ class UsageByUserDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UsageByUserDto? fromJson(dynamic value) { + upgradeDto(value, "UsageByUserDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_create_dto.dart b/mobile/openapi/lib/model/user_admin_create_dto.dart index db514a1d571b6..f2709be57b640 100644 --- a/mobile/openapi/lib/model/user_admin_create_dto.dart +++ b/mobile/openapi/lib/model/user_admin_create_dto.dart @@ -105,6 +105,7 @@ class UserAdminCreateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminCreateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminCreateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_delete_dto.dart b/mobile/openapi/lib/model/user_admin_delete_dto.dart index 7778b15775d0f..2cf68ad7b25ee 100644 --- a/mobile/openapi/lib/model/user_admin_delete_dto.dart +++ b/mobile/openapi/lib/model/user_admin_delete_dto.dart @@ -50,6 +50,7 @@ class UserAdminDeleteDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminDeleteDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index af1ad3ad1c1bf..e5ae8e1d4ef27 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -22,6 +22,7 @@ class UserAdminResponseDto { required this.license, required this.name, required this.oauthId, + required this.profileChangedAt, required this.profileImagePath, required this.quotaSizeInBytes, required this.quotaUsageInBytes, @@ -49,6 +50,8 @@ class UserAdminResponseDto { String oauthId; + DateTime profileChangedAt; + String profileImagePath; int? quotaSizeInBytes; @@ -74,6 +77,7 @@ class UserAdminResponseDto { other.license == license && other.name == name && other.oauthId == oauthId && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath && other.quotaSizeInBytes == quotaSizeInBytes && other.quotaUsageInBytes == quotaUsageInBytes && @@ -94,6 +98,7 @@ class UserAdminResponseDto { (license == null ? 0 : license!.hashCode) + (name.hashCode) + (oauthId.hashCode) + + (profileChangedAt.hashCode) + (profileImagePath.hashCode) + (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + @@ -103,7 +108,7 @@ class UserAdminResponseDto { (updatedAt.hashCode); @override - String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -124,6 +129,7 @@ class UserAdminResponseDto { } json[r'name'] = this.name; json[r'oauthId'] = this.oauthId; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; if (this.quotaSizeInBytes != null) { json[r'quotaSizeInBytes'] = this.quotaSizeInBytes; @@ -150,6 +156,7 @@ class UserAdminResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminResponseDto"); if (value is Map) { final json = value.cast(); @@ -163,6 +170,7 @@ class UserAdminResponseDto { license: UserLicense.fromJson(json[r'license']), name: mapValueOfType(json, r'name')!, oauthId: mapValueOfType(json, r'oauthId')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), @@ -226,6 +234,7 @@ class UserAdminResponseDto { 'license', 'name', 'oauthId', + 'profileChangedAt', 'profileImagePath', 'quotaSizeInBytes', 'quotaUsageInBytes', diff --git a/mobile/openapi/lib/model/user_admin_update_dto.dart b/mobile/openapi/lib/model/user_admin_update_dto.dart index dd0db767fe6b0..6c6f73ae8e9e1 100644 --- a/mobile/openapi/lib/model/user_admin_update_dto.dart +++ b/mobile/openapi/lib/model/user_admin_update_dto.dart @@ -119,6 +119,7 @@ class UserAdminUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserAdminUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserAdminUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_license.dart b/mobile/openapi/lib/model/user_license.dart index c7abb085f29c7..9bed8d5c43633 100644 --- a/mobile/openapi/lib/model/user_license.dart +++ b/mobile/openapi/lib/model/user_license.dart @@ -52,6 +52,7 @@ class UserLicense { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserLicense? fromJson(dynamic value) { + upgradeDto(value, "UserLicense"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index d3927df8d7ee3..23d9ea84ecd82 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -88,6 +88,7 @@ class UserPreferencesResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 2841c2f572c11..208dbf686078a 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -178,6 +178,7 @@ class UserPreferencesUpdateDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserPreferencesUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "UserPreferencesUpdateDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index 41c18998483b9..a02da299481b8 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -17,6 +17,7 @@ class UserResponseDto { required this.email, required this.id, required this.name, + required this.profileChangedAt, required this.profileImagePath, }); @@ -28,6 +29,8 @@ class UserResponseDto { String name; + DateTime profileChangedAt; + String profileImagePath; @override @@ -36,6 +39,7 @@ class UserResponseDto { other.email == email && other.id == id && other.name == name && + other.profileChangedAt == profileChangedAt && other.profileImagePath == profileImagePath; @override @@ -45,10 +49,11 @@ class UserResponseDto { (email.hashCode) + (id.hashCode) + (name.hashCode) + + (profileChangedAt.hashCode) + (profileImagePath.hashCode); @override - String toString() => 'UserResponseDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileImagePath=$profileImagePath]'; + String toString() => 'UserResponseDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath]'; Map toJson() { final json = {}; @@ -56,6 +61,7 @@ class UserResponseDto { json[r'email'] = this.email; json[r'id'] = this.id; json[r'name'] = this.name; + json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String(); json[r'profileImagePath'] = this.profileImagePath; return json; } @@ -64,6 +70,7 @@ class UserResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserResponseDto? fromJson(dynamic value) { + upgradeDto(value, "UserResponseDto"); if (value is Map) { final json = value.cast(); @@ -72,6 +79,7 @@ class UserResponseDto { email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, + profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, ); } @@ -124,6 +132,7 @@ class UserResponseDto { 'email', 'id', 'name', + 'profileChangedAt', 'profileImagePath', }; } diff --git a/mobile/openapi/lib/model/user_update_me_dto.dart b/mobile/openapi/lib/model/user_update_me_dto.dart index 2d665fc7847b8..8f3f4df37ad81 100644 --- a/mobile/openapi/lib/model/user_update_me_dto.dart +++ b/mobile/openapi/lib/model/user_update_me_dto.dart @@ -82,6 +82,7 @@ class UserUpdateMeDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static UserUpdateMeDto? fromJson(dynamic value) { + upgradeDto(value, "UserUpdateMeDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_access_token_response_dto.dart b/mobile/openapi/lib/model/validate_access_token_response_dto.dart index e970f7e840a80..5e36efcfedf5b 100644 --- a/mobile/openapi/lib/model/validate_access_token_response_dto.dart +++ b/mobile/openapi/lib/model/validate_access_token_response_dto.dart @@ -40,6 +40,7 @@ class ValidateAccessTokenResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateAccessTokenResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateAccessTokenResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_dto.dart b/mobile/openapi/lib/model/validate_library_dto.dart index 05e122b1a1170..08199e3aa66c8 100644 --- a/mobile/openapi/lib/model/validate_library_dto.dart +++ b/mobile/openapi/lib/model/validate_library_dto.dart @@ -46,6 +46,7 @@ class ValidateLibraryDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart index 23aac0b74255a..11fbbd74c2aaa 100644 --- a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart @@ -62,6 +62,7 @@ class ValidateLibraryImportPathResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryImportPathResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryImportPathResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/openapi/lib/model/validate_library_response_dto.dart b/mobile/openapi/lib/model/validate_library_response_dto.dart index b213f9ba98943..e0dc2a2d14233 100644 --- a/mobile/openapi/lib/model/validate_library_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_response_dto.dart @@ -40,6 +40,7 @@ class ValidateLibraryResponseDto { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static ValidateLibraryResponseDto? fromJson(dynamic value) { + upgradeDto(value, "ValidateLibraryResponseDto"); if (value is Map) { final json = value.cast(); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index c9493f6490b72..87aa987df0417 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -78,6 +78,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.0.0" + background_downloader: + dependency: "direct main" + description: + name: background_downloader + sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28" + url: "https://pub.dev" + source: hosted + version: "8.5.5" boolean_selector: dependency: transitive description: @@ -258,18 +266,18 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" + sha256: "2056db5241f96cdc0126bd94459fc4cdc13876753768fc7a31c425e50a7177d0" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "6.0.5" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "2.0.1" convert: dependency: transitive description: @@ -370,10 +378,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" + sha256: a7fd703482b391a87d60b6061d04dfdeab07826b96f9abd8f5ed98068acc0074 url: "https://pub.dev" source: hosted - version: "9.1.2" + version: "10.1.2" device_info_plus_platform_interface: dependency: transitive description: @@ -524,18 +532,18 @@ packages: dependency: "direct dev" description: name: flutter_launcher_icons - sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + sha256: "619817c4b65b322b5104b6bb6dfe6cda62d9729bd7ad4303ecc8b4e690a67a77" url: "https://pub.dev" source: hosted - version: "0.13.1" + version: "0.14.1" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" flutter_local_notifications: dependency: "direct main" description: @@ -744,10 +752,10 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "0.13.6" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -836,6 +844,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1+1" + immich_mobile_immich_lint: + dependency: "direct dev" + description: + path: immich_lint + relative: true + source: path + version: "0.0.0" integration_test: dependency: "direct dev" description: flutter @@ -925,10 +940,10 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "5.0.0" logging: dependency: "direct main" description: @@ -1196,10 +1211,10 @@ packages: dependency: "direct main" description: name: photo_manager - sha256: "1e8bbe46a6858870e34c976aafd85378bed221ce31c1201961eba9ad3d94df9f" + sha256: "32a1ce1095aeaaa792a29f28c1f74613aa75109f21c2d4ab85be3ad9964230a4" url: "https://pub.dev" source: hosted - version: "3.2.3" + version: "3.5.0" photo_manager_image_provider: dependency: "direct main" description: @@ -1846,5 +1861,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.24.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.3" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3db5457c8c1c1..fd1634ba07b01 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,18 +2,18 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.114.0+158 +version: 1.117.0+162 environment: sdk: '>=3.3.0 <4.0.0' - flutter: 3.24.0 + flutter: 3.24.3 dependencies: flutter: sdk: flutter path_provider_ios: - photo_manager: ^3.2.3 + photo_manager: ^3.5.0 photo_manager_image_provider: ^2.1.1 flutter_hooks: ^0.20.4 hooks_riverpod: ^2.4.9 @@ -32,7 +32,7 @@ dependencies: flutter_svg: ^2.0.9 package_info_plus: ^8.0.1 url_launcher: ^6.2.4 - http: ^0.13.6 + http: ^1.1.0 cancellation_token_http: ^2.0.0 easy_localization: ^3.0.3 share_plus: ^10.0.0 @@ -47,8 +47,8 @@ dependencies: isar: ^3.1.0+1 isar_flutter_libs: ^3.1.0+1 permission_handler: ^11.2.0 - device_info_plus: ^9.1.1 - connectivity_plus: ^5.0.2 + device_info_plus: ^10.0.0 + connectivity_plus: ^6.0.0 wakelock_plus: ^1.1.4 flutter_local_notifications: ^17.2.1+2 timezone: ^0.9.2 @@ -56,6 +56,7 @@ dependencies: thumbhash: 0.1.0+1 async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme + background_downloader: ^8.5.5 #image editing packages crop_image: ^1.0.13 @@ -86,18 +87,20 @@ dependency_overrides: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^4.0.0 + flutter_lints: ^5.0.0 build_runner: ^2.4.8 auto_route_generator: ^9.0.0 - flutter_launcher_icons: ^0.13.1 + flutter_launcher_icons: ^0.14.0 flutter_native_splash: ^2.3.9 isar_generator: ^3.1.0+1 integration_test: sdk: flutter - custom_lint: ^0.6.0 + custom_lint: ^0.6.4 riverpod_lint: ^2.3.7 riverpod_generator: ^2.3.9 mocktail: ^1.0.3 + immich_mobile_immich_lint: + path: './immich_lint' flutter: uses-material-design: true diff --git a/mobile/scripts/fdroid_build_isar.sh b/mobile/scripts/fdroid_build_isar.sh old mode 100644 new mode 100755 index 44f59c69aee0e..41517737c94f5 --- a/mobile/scripts/fdroid_build_isar.sh +++ b/mobile/scripts/fdroid_build_isar.sh @@ -1,6 +1,8 @@ #!/usr/bin/env sh -cd .isar || exit +test -d .isar || exit +cp .isar-cargo.lock .isar/Cargo.lock +(cd .isar || exit bash tool/build_android.sh x86 bash tool/build_android.sh x64 bash tool/build_android.sh armv7 @@ -13,4 +15,4 @@ mv libisar_android_x64.so libisar.so mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/ mv libisar_android_x86.so libisar.so mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/ -cd .. \ No newline at end of file +) \ No newline at end of file diff --git a/mobile/scripts/fdroid_update_isar.sh b/mobile/scripts/fdroid_update_isar.sh new file mode 100755 index 0000000000000..814f50a8a15bc --- /dev/null +++ b/mobile/scripts/fdroid_update_isar.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +isar_version="$(awk '/isar: /{gsub(/\^/, "", $2); print $2}' pubspec.yaml)" +checked_out_version="$(git -C .isar describe --tags)" + +if [ "$isar_version" = "$checked_out_version" ]; then + echo "isar is up-to-date." + exit 0 +fi +echo "Updating from version $checked_out_version to $isar_version." + +git -C .isar checkout "$isar_version" +cargo generate-lockfile --manifest-path .isar/Cargo.toml +mv .isar/Cargo.lock .isar-cargo.lock diff --git a/mobile/test/modules/activity/activity_statistics_provider_test.dart b/mobile/test/modules/activity/activity_statistics_provider_test.dart index 9edabcc0d0b66..0216528ddd31f 100644 --- a/mobile/test/modules/activity/activity_statistics_provider_test.dart +++ b/mobile/test/modules/activity/activity_statistics_provider_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/models/activities/activity.model.dart'; import 'package:immich_mobile/providers/activity_service.provider.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:mocktail/mocktail.dart'; @@ -25,7 +26,7 @@ void main() { test('Returns the proper count family', () async { when( () => activityMock.getStatistics('test-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 5); + ).thenAnswer((_) async => const ActivityStats(comments: 5)); // Read here to make the getStatistics call container.read(activityStatisticsProvider('test-album', 'test-asset')); @@ -50,7 +51,7 @@ void main() { test('Adds activity', () async { when( () => activityMock.getStatistics('test-album'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('test-album'); container.listen( @@ -71,7 +72,7 @@ void main() { test('Removes activity', () async { when( () => activityMock.getStatistics('new-album', assetId: 'test-asset'), - ).thenAnswer((_) async => 10); + ).thenAnswer((_) async => const ActivityStats(comments: 10)); final provider = activityStatisticsProvider('new-album', 'test-asset'); container.listen( diff --git a/mobile/test/modules/shared/shared_mocks.dart b/mobile/test/modules/shared/shared_mocks.dart index a2aa7b2617b7f..013232da3ee82 100644 --- a/mobile/test/modules/shared/shared_mocks.dart +++ b/mobile/test/modules/shared/shared_mocks.dart @@ -1,11 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/services/hash.service.dart'; import 'package:mocktail/mocktail.dart'; -class MockHashService extends Mock implements HashService {} - class MockCurrentUserProvider extends StateNotifier with Mock implements CurrentUserProvider { diff --git a/mobile/test/modules/shared/sync_service_test.dart b/mobile/test/modules/shared/sync_service_test.dart index 07437289beeff..c85487c7d04da 100644 --- a/mobile/test/modules/shared/sync_service_test.dart +++ b/mobile/test/modules/shared/sync_service_test.dart @@ -1,16 +1,21 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; -import 'package:isar/isar.dart'; +import 'package:mocktail/mocktail.dart'; +import '../../repository.mocks.dart'; +import '../../service.mocks.dart'; import '../../test_utils.dart'; -import 'shared_mocks.dart'; void main() { + int assetIdCounter = 0; Asset makeAsset({ required String checksum, String? localId, @@ -19,6 +24,7 @@ void main() { }) { final DateTime date = DateTime(2000); return Asset( + id: assetIdCounter++, checksum: checksum, localId: localId, remoteId: remoteId, @@ -36,8 +42,16 @@ void main() { } group('Test SyncService grouped', () { - late final Isar db; final MockHashService hs = MockHashService(); + final MockEntityService entityService = MockEntityService(); + final MockAlbumRepository albumRepository = MockAlbumRepository(); + final MockAssetRepository assetRepository = MockAssetRepository(); + final MockExifInfoRepository exifInfoRepository = MockExifInfoRepository(); + final MockUserRepository userRepository = MockUserRepository(); + final MockETagRepository eTagRepository = MockETagRepository(); + final MockAlbumMediaRepository albumMediaRepository = + MockAlbumMediaRepository(); + final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository(); final owner = User( id: "1", updatedAt: DateTime.now(), @@ -45,9 +59,10 @@ void main() { name: "first last", isAdmin: false, ); + late SyncService s; setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); - db = await TestUtils.initIsar(); + final db = await TestUtils.initIsar(); ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); @@ -61,19 +76,51 @@ void main() { makeAsset(checksum: "e", localId: "3"), ]; setUp(() { - db.writeTxnSync(() { - db.assets.clearSync(); - db.assets.putAllSync(initialAssets); - }); + s = SyncService( + hs, + entityService, + albumMediaRepository, + albumApiRepository, + albumRepository, + assetRepository, + exifInfoRepository, + userRepository, + eTagRepository, + ); + when(() => eTagRepository.get(owner.isarId)) + .thenAnswer((_) async => ETag(id: owner.id, time: DateTime.now())); + when(() => eTagRepository.deleteByIds(["1"])).thenAnswer((_) async {}); + when(() => eTagRepository.upsertAll(any())).thenAnswer((_) async {}); + when(() => userRepository.me()).thenAnswer((_) async => owner); + when(() => userRepository.getAll(sortBy: UserSort.id)) + .thenAnswer((_) async => [owner]); + when(() => userRepository.getAllAccessible()) + .thenAnswer((_) async => [owner]); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => initialAssets); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[3], null, null]); + when(() => assetRepository.updateAll(any())).thenAnswer((_) async => []); + when(() => assetRepository.deleteById(any())).thenAnswer((_) async {}); + when(() => exifInfoRepository.updateAll(any())) + .thenAnswer((_) async => []); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); }); test('test inserting existing assets', () async { - SyncService s = SyncService(db, hs); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "c", remoteId: "1-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -81,11 +128,10 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isFalse); - expect(db.assets.countSync(), 5); + verifyNever(() => assetRepository.updateAll(any())); }); test('test inserting new assets', () async { - SyncService s = SyncService(db, hs); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "2-1"), @@ -94,7 +140,6 @@ void main() { makeAsset(checksum: "f", remoteId: "1-4"), makeAsset(checksum: "g", remoteId: "3-1"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -102,11 +147,14 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 7); + final updatedAsset = initialAssets[3].updatedCopy(remoteAssets[3]); + verify( + () => assetRepository + .updateAll([remoteAssets[4], remoteAssets[5], updatedAsset]), + ); }); test('test syncing duplicate assets', () async { - SyncService s = SyncService(db, hs); final List remoteAssets = [ makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "b", remoteId: "1-1"), @@ -115,7 +163,6 @@ void main() { makeAsset(checksum: "i", remoteId: "2-1c"), makeAsset(checksum: "j", remoteId: "2-1d"), ]; - expect(db.assets.countSync(), 5); final bool c1 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -123,7 +170,12 @@ void main() { refreshUsers: () => [owner], ); expect(c1, isTrue); - expect(db.assets.countSync(), 8); + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => remoteAssets); final bool c2 = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: _failDiff, @@ -131,7 +183,13 @@ void main() { refreshUsers: () => [owner], ); expect(c2, isFalse); - expect(db.assets.countSync(), 8); + final currentState = [...remoteAssets]; + when( + () => assetRepository.getAll( + ownerId: owner.isarId, + sortBy: AssetSort.checksum, + ), + ).thenAnswer((_) async => currentState); remoteAssets.removeAt(4); final bool c3 = await s.syncRemoteAssetsToDb( users: [owner], @@ -140,7 +198,6 @@ void main() { refreshUsers: () => [owner], ); expect(c3, isTrue); - expect(db.assets.countSync(), 7); remoteAssets.add(makeAsset(checksum: "k", remoteId: "2-1e")); remoteAssets.add(makeAsset(checksum: "l", remoteId: "2-2")); final bool c4 = await s.syncRemoteAssetsToDb( @@ -150,11 +207,21 @@ void main() { refreshUsers: () => [owner], ); expect(c4, isTrue); - expect(db.assets.countSync(), 9); }); test('test efficient sync', () async { - SyncService s = SyncService(db, hs); + when( + () => assetRepository.deleteAllByRemoteId( + [initialAssets[1].remoteId!, initialAssets[2].remoteId!], + state: AssetState.remote, + ), + ).thenAnswer((_) async {}); + when( + () => assetRepository + .getAllByRemoteId(["2-1", "1-1"], state: AssetState.merged), + ).thenAnswer((_) async => [initialAssets[2]]); + when(() => assetRepository.getAllByOwnerIdChecksum(any(), any())) + .thenAnswer((_) async => [initialAssets[0], null, null]); //afg final List toUpsert = [ makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "f", remoteId: "0-2"), // new @@ -162,6 +229,8 @@ void main() { ]; toUpsert[0].isFavorite = true; final List toDelete = ["2-1", "1-1"]; + final expected = [...toUpsert]; + expected[0].id = initialAssets[0].id; final bool c = await s.syncRemoteAssetsToDb( users: [owner], getChangedAssets: (user, since) async => (toUpsert, toDelete), @@ -169,7 +238,7 @@ void main() { refreshUsers: () => throw Exception(), ); expect(c, isTrue); - expect(db.assets.countSync(), 6); + verify(() => assetRepository.updateAll(expected)); }); }); } diff --git a/mobile/test/repository.mocks.dart b/mobile/test/repository.mocks.dart new file mode 100644 index 0000000000000..c76a003eec2a0 --- /dev/null +++ b/mobile/test/repository.mocks.dart @@ -0,0 +1,31 @@ +import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/interfaces/album_api.interface.dart'; +import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/interfaces/backup.interface.dart'; +import 'package:immich_mobile/interfaces/etag.interface.dart'; +import 'package:immich_mobile/interfaces/exif_info.interface.dart'; +import 'package:immich_mobile/interfaces/file_media.interface.dart'; +import 'package:immich_mobile/interfaces/user.interface.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAlbumRepository extends Mock implements IAlbumRepository {} + +class MockAssetRepository extends Mock implements IAssetRepository {} + +class MockUserRepository extends Mock implements IUserRepository {} + +class MockBackupRepository extends Mock implements IBackupRepository {} + +class MockExifInfoRepository extends Mock implements IExifInfoRepository {} + +class MockETagRepository extends Mock implements IETagRepository {} + +class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {} + +class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} + +class MockFileMediaRepository extends Mock implements IFileMediaRepository {} + +class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {} diff --git a/mobile/test/service.mocks.dart b/mobile/test/service.mocks.dart new file mode 100644 index 0000000000000..de49a98cc4e5a --- /dev/null +++ b/mobile/test/service.mocks.dart @@ -0,0 +1,16 @@ +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/entity.service.dart'; +import 'package:immich_mobile/services/hash.service.dart'; +import 'package:immich_mobile/services/sync.service.dart'; +import 'package:immich_mobile/services/user.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockApiService extends Mock implements ApiService {} + +class MockUserService extends Mock implements UserService {} + +class MockSyncService extends Mock implements SyncService {} + +class MockHashService extends Mock implements HashService {} + +class MockEntityService extends Mock implements EntityService {} diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart new file mode 100644 index 0000000000000..fb46dceed592f --- /dev/null +++ b/mobile/test/services/album.service_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../fixtures/album.stub.dart'; +import '../fixtures/asset.stub.dart'; +import '../fixtures/user.stub.dart'; +import '../repository.mocks.dart'; +import '../service.mocks.dart'; + +void main() { + late AlbumService sut; + late MockUserService userService; + late MockSyncService syncService; + late MockEntityService entityService; + late MockAlbumRepository albumRepository; + late MockAssetRepository assetRepository; + late MockBackupRepository backupRepository; + late MockAlbumMediaRepository albumMediaRepository; + late MockAlbumApiRepository albumApiRepository; + + setUp(() { + userService = MockUserService(); + syncService = MockSyncService(); + entityService = MockEntityService(); + albumRepository = MockAlbumRepository(); + assetRepository = MockAssetRepository(); + backupRepository = MockBackupRepository(); + albumMediaRepository = MockAlbumMediaRepository(); + albumApiRepository = MockAlbumApiRepository(); + + when(() => albumRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + when(() => assetRepository.transaction(any())).thenAnswer( + (call) => (call.positionalArguments.first as Function).call(), + ); + + sut = AlbumService( + userService, + syncService, + entityService, + albumRepository, + assetRepository, + backupRepository, + albumMediaRepository, + albumApiRepository, + ); + }); + + group('refreshDeviceAlbums', () { + test('empty selection with one album in db', () async { + when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)) + .thenAnswer((_) async => []); + when(() => backupRepository.getIdsBySelection(BackupSelection.select)) + .thenAnswer((_) async => []); + when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1); + when(() => syncService.removeAllLocalAlbumsAndAssets()) + .thenAnswer((_) async => true); + final result = await sut.refreshDeviceAlbums(); + expect(result, false); + verify(() => syncService.removeAllLocalAlbumsAndAssets()); + }); + + test('one selected albums, two on device', () async { + when(() => backupRepository.getIdsBySelection(BackupSelection.exclude)) + .thenAnswer((_) async => []); + when(() => backupRepository.getIdsBySelection(BackupSelection.select)) + .thenAnswer((_) async => [AlbumStub.oneAsset.localId!]); + when(() => albumMediaRepository.getAll()) + .thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]); + when(() => syncService.syncLocalAlbumAssetsToDb(any(), any())) + .thenAnswer((_) async => true); + final result = await sut.refreshDeviceAlbums(); + expect(result, true); + verify( + () => syncService.syncLocalAlbumAssetsToDb([AlbumStub.oneAsset], null), + ).called(1); + verifyNoMoreInteractions(syncService); + }); + }); + group('refreshRemoteAlbums', () { + test('isShared: false', () async { + when(() => userService.refreshUsers()).thenAnswer((_) async => true); + when(() => albumApiRepository.getAll(shared: null)) + .thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]); + when( + () => syncService.syncRemoteAlbumsToDb( + [AlbumStub.oneAsset, AlbumStub.twoAsset], + isShared: false, + ), + ).thenAnswer((_) async => true); + final result = await sut.refreshRemoteAlbums(isShared: false); + expect(result, true); + verify(() => userService.refreshUsers()).called(1); + verify(() => albumApiRepository.getAll(shared: null)).called(1); + verify( + () => syncService.syncRemoteAlbumsToDb( + [AlbumStub.oneAsset, AlbumStub.twoAsset], + isShared: false, + ), + ).called(1); + verifyNoMoreInteractions(userService); + verifyNoMoreInteractions(albumApiRepository); + verifyNoMoreInteractions(syncService); + }); + }); + + group('createAlbum', () { + test('shared with assets', () async { + when( + () => albumApiRepository.create( + "name", + assetIds: any(named: "assetIds"), + sharedUserIds: any(named: "sharedUserIds"), + ), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + when( + () => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + when( + () => albumRepository.create(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.twoAsset); + + final result = + await sut.createAlbum("name", [AssetStub.image1], [UserStub.user1]); + expect(result, AlbumStub.twoAsset); + verify( + () => albumApiRepository.create( + "name", + assetIds: [AssetStub.image1.remoteId!], + sharedUserIds: [UserStub.user1.id], + ), + ).called(1); + verify( + () => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset), + ).called(1); + }); + }); + + group('addAdditionalAssetToAlbum', () { + test('one added, one duplicate', () async { + when( + () => albumApiRepository.addAssets(AlbumStub.oneAsset.remoteId!, any()), + ).thenAnswer( + (_) async => ( + added: [AssetStub.image2.remoteId!], + duplicates: [AssetStub.image1.remoteId!] + ), + ); + when( + () => albumRepository.get(AlbumStub.oneAsset.id), + ).thenAnswer((_) async => AlbumStub.oneAsset); + when( + () => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]), + ).thenAnswer((_) async {}); + when( + () => albumRepository.removeAssets(AlbumStub.oneAsset, []), + ).thenAnswer((_) async {}); + when( + () => albumRepository.recalculateMetadata(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + when( + () => albumRepository.update(AlbumStub.oneAsset), + ).thenAnswer((_) async => AlbumStub.oneAsset); + + final result = await sut.addAdditionalAssetToAlbum( + [AssetStub.image1, AssetStub.image2], + AlbumStub.oneAsset, + ); + + expect(result != null, true); + expect(result!.alreadyInAlbum, [AssetStub.image1.remoteId!]); + expect(result.successfullyAdded, 1); + }); + }); + + group('addAdditionalUserToAlbum', () { + test('one added', () async { + when( + () => + albumApiRepository.addUsers(AlbumStub.emptyAlbum.remoteId!, any()), + ).thenAnswer( + (_) async => AlbumStub.sharedWithUser, + ); + when( + () => entityService + .fillAlbumWithDatabaseEntities(AlbumStub.sharedWithUser), + ).thenAnswer((_) async => AlbumStub.sharedWithUser); + when( + () => albumRepository.update(AlbumStub.sharedWithUser), + ).thenAnswer((_) async => AlbumStub.sharedWithUser); + + final result = await sut.addAdditionalUserToAlbum( + [UserStub.user2.id], + AlbumStub.emptyAlbum, + ); + expect(result, true); + }); + }); +} diff --git a/mobile/test/services/entity.service_test.dart b/mobile/test/services/entity.service_test.dart new file mode 100644 index 0000000000000..8c8b49a7e02a5 --- /dev/null +++ b/mobile/test/services/entity.service_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; +import 'package:immich_mobile/services/entity.service.dart'; +import 'package:mocktail/mocktail.dart'; +import '../fixtures/asset.stub.dart'; +import '../fixtures/user.stub.dart'; +import '../repository.mocks.dart'; + +void main() { + late EntityService sut; + late MockAssetRepository assetRepository; + late MockUserRepository userRepository; + + setUp(() { + assetRepository = MockAssetRepository(); + userRepository = MockUserRepository(); + sut = EntityService(assetRepository, userRepository); + }); + + group('fillAlbumWithDatabaseEntities', () { + test('remote album with owner, thumbnail, sharedUsers and assets', + () async { + final Album album = Album( + name: "album-with-two-assets-and-two-users", + localId: "album-with-two-assets-and-two-users-local", + remoteId: "album-with-two-assets-and-two-users-remote", + createdAt: DateTime(2001), + modifiedAt: DateTime(2010), + shared: true, + activityEnabled: true, + startDate: DateTime(2019), + endDate: DateTime(2020), + ) + ..remoteThumbnailAssetId = AssetStub.image1.remoteId + ..assets.addAll([AssetStub.image1, AssetStub.image1]) + ..owner.value = UserStub.user1 + ..sharedUsers.addAll([UserStub.admin, UserStub.admin]); + + when(() => userRepository.get(album.ownerId!)) + .thenAnswer((_) async => UserStub.admin); + + when(() => assetRepository.getByRemoteId(AssetStub.image1.remoteId!)) + .thenAnswer((_) async => AssetStub.image1); + + when(() => userRepository.getByIds(any())) + .thenAnswer((_) async => [UserStub.user1, UserStub.user2]); + + when(() => assetRepository.getAllByRemoteId(any())) + .thenAnswer((_) async => [AssetStub.image1, AssetStub.image2]); + + await sut.fillAlbumWithDatabaseEntities(album); + expect(album.owner.value, UserStub.admin); + expect(album.thumbnail.value, AssetStub.image1); + expect(album.remoteUsers.toSet(), {UserStub.user1, UserStub.user2}); + expect(album.remoteAssets.toSet(), {AssetStub.image1, AssetStub.image2}); + }); + + test('remote album without any info', () async { + makeEmptyAlbum() => Album( + name: "album-without-info", + localId: "album-without-info-local", + remoteId: "album-without-info-remote", + createdAt: DateTime(2001), + modifiedAt: DateTime(2010), + shared: false, + activityEnabled: false, + ); + + final album = makeEmptyAlbum(); + await sut.fillAlbumWithDatabaseEntities(album); + verifyNoMoreInteractions(assetRepository); + verifyNoMoreInteractions(userRepository); + expect(album, makeEmptyAlbum()); + }); + }); +} diff --git a/open-api/bin/generate-open-api.sh b/open-api/bin/generate-open-api.sh index 2ca04630468f9..bf8b24b55703b 100755 --- a/open-api/bin/generate-open-api.sh +++ b/open-api/bin/generate-open-api.sh @@ -9,11 +9,7 @@ function dart { wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache patch --no-backup-if-mismatch -u native_class.mustache header}} -{{>part_of}} -class ApiClient { - ApiClient({this.basePath = '{{{basePath}}}', this.authentication,}); - - final String basePath; - final Authentication? authentication; - - var _client = Client(); - final _defaultHeaderMap = {}; - - /// Returns the current HTTP [Client] instance to use in this class. - /// - /// The return value is guaranteed to never be null. - Client get client => _client; - - /// Requests to use a new HTTP [Client] in this class. - set client(Client newClient) { - _client = newClient; - } - - Map get defaultHeaderMap => _defaultHeaderMap; - - void addDefaultHeader(String key, String value) { - _defaultHeaderMap[key] = value; - } - - // We don't use a Map for queryParams. - // If collectionFormat is 'multi', a key might appear multiple times. - Future invokeAPI( - String path, - String method, - List queryParams, - Object? body, - Map headerParams, - Map formParams, - String? contentType, - ) async { - await authentication?.applyToParams(queryParams, headerParams); - - headerParams.addAll(_defaultHeaderMap); - if (contentType != null) { - headerParams['Content-Type'] = contentType; - } - - final urlEncodedQueryParams = queryParams.map((param) => '$param'); - final queryString = urlEncodedQueryParams.isNotEmpty ? '?${urlEncodedQueryParams.join('&')}' : ''; - final uri = Uri.parse('$basePath$path$queryString'); - - try { - // Special case for uploading a single file which isn't a 'multipart/form-data'. - if ( - body is MultipartFile && (contentType == null || - !contentType.toLowerCase().startsWith('multipart/form-data')) - ) { - final request = StreamedRequest(method, uri); - request.headers.addAll(headerParams); - request.contentLength = body.length; - body.finalize().listen( - request.sink.add, - onDone: request.sink.close, - // ignore: avoid_types_on_closure_parameters - onError: (Object error, StackTrace trace) => request.sink.close(), - cancelOnError: true, - ); - final response = await _client.send(request); - return Response.fromStream(response); - } - - if (body is MultipartRequest) { - final request = MultipartRequest(method, uri); - request.fields.addAll(body.fields); - request.files.addAll(body.files); - request.headers.addAll(body.headers); - request.headers.addAll(headerParams); - final response = await _client.send(request); - return Response.fromStream(response); - } - - final msgBody = contentType == 'application/x-www-form-urlencoded' - ? formParams - : await serializeAsync(body); - final nullableHeaderParams = headerParams.isEmpty ? null : headerParams; - - switch(method) { - case 'POST': return await _client.post(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PUT': return await _client.put(uri, headers: nullableHeaderParams, body: msgBody,); - case 'DELETE': return await _client.delete(uri, headers: nullableHeaderParams, body: msgBody,); - case 'PATCH': return await _client.patch(uri, headers: nullableHeaderParams, body: msgBody,); - case 'HEAD': return await _client.head(uri, headers: nullableHeaderParams,); - case 'GET': return await _client.get(uri, headers: nullableHeaderParams,); - } - } on SocketException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Socket operation failed: $method $path', - error, - trace, - ); - } on TlsException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'TLS/SSL communication failed: $method $path', - error, - trace, - ); - } on IOException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'I/O operation failed: $method $path', - error, - trace, - ); - } on ClientException catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'HTTP connection failed: $method $path', - error, - trace, - ); - } on Exception catch (error, trace) { - throw ApiException.withInner( - HttpStatus.badRequest, - 'Exception occurred: $method $path', - error, - trace, - ); - } - - throw ApiException( - HttpStatus.badRequest, - 'Invalid HTTP operation: $method $path', - ); - } -{{#native_serialization}} - - Future deserializeAsync(String value, String targetType, {bool growable = false,}) async => - // ignore: deprecated_member_use_from_same_package - deserialize(value, targetType, growable: growable); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use deserializeAsync() instead.') - dynamic deserialize(String value, String targetType, {bool growable = false,}) { - // Remove all spaces. Necessary for regular expressions as well. - targetType = targetType.replaceAll(' ', ''); // ignore: parameter_assignments - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? value - : fromJson(json.decode(value), targetType, growable: growable); - } -{{/native_serialization}} - - // ignore: deprecated_member_use_from_same_package - Future serializeAsync(Object? value) async => serialize(value); - - @Deprecated('Scheduled for removal in OpenAPI Generator 6.x. Use serializeAsync() instead.') - String serialize(Object? value) => value == null ? '' : json.encode(value); - -{{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { - upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': - return value is String ? value : value.toString(); - case 'int': - return value is int ? value : int.parse('$value'); - case 'double': - return value is double ? value : double.parse('$value'); - case 'bool': - if (value is bool) { - return value; - } - final valueString = '$value'.toLowerCase(); - return valueString == 'true' || valueString == '1'; - case 'DateTime': - return value is DateTime ? value : DateTime.tryParse(value); - {{#models}} - {{#model}} - case '{{{classname}}}': - {{#isEnum}} - {{#native_serialization}}return {{{classname}}}TypeTransformer().decode(value);{{/native_serialization}} - {{/isEnum}} - {{^isEnum}} - return {{{classname}}}.fromJson(value); - {{/isEnum}} - {{/model}} - {{/models}} - default: - dynamic match; - if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toList(growable: growable); - } - if (value is Set && (match = _regSet.firstMatch(targetType)?.group(1)) != null) { - return value - .map((dynamic v) => fromJson(v, match, growable: growable,)) - .toSet(); - } - if (value is Map && (match = _regMap.firstMatch(targetType)?.group(1)) != null) { - return Map.fromIterables( - value.keys.cast(), - value.values.map((dynamic v) => fromJson(v, match, growable: growable,)), - ); - } - } - } on Exception catch (error, trace) { - throw ApiException.withInner(HttpStatus.internalServerError, 'Exception during deserialization.', error, trace,); - } - throw ApiException(HttpStatus.internalServerError, 'Could not find a suitable class for deserialization',); - } -{{/native_serialization}} -} -{{#native_serialization}} - -/// Primarily intended for use in an isolate. -class DeserializationMessage { - const DeserializationMessage({ - required this.json, - required this.targetType, - this.growable = false, - }); - - /// The JSON value to deserialize. - final String json; - - /// Target type to deserialize to. - final String targetType; - - /// Whether to make deserialized lists or maps growable. - final bool growable; -} - -/// Primarily intended for use in an isolate. -Future decodeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : json.decode(message.json); -} - -/// Primarily intended for use in an isolate. -Future deserializeAsync(DeserializationMessage message) async { - // Remove all spaces. Necessary for regular expressions as well. - final targetType = message.targetType.replaceAll(' ', ''); - - // If the expected target type is String, nothing to do... - return targetType == 'String' - ? message.json - : ApiClient.fromJson( - json.decode(message.json), - targetType, - growable: message.growable, - ); -} -{{/native_serialization}} - -/// Primarily intended for use in an isolate. -Future serializeAsync(Object? value) async => value == null ? '' : json.encode(value); diff --git a/open-api/templates/mobile/api_client.mustache.patch b/open-api/templates/mobile/api_client.mustache.patch deleted file mode 100644 index 3805cd8f7934a..0000000000000 --- a/open-api/templates/mobile/api_client.mustache.patch +++ /dev/null @@ -1,10 +0,0 @@ ---- api_client.mustache 2024-08-13 14:29:04.056364916 -0500 -+++ api_client_new.mustache 2024-08-13 14:29:36.224410735 -0500 -@@ -159,6 +159,7 @@ - {{#native_serialization}} - /// Returns a native instance of an OpenAPI class matching the [specified type][targetType]. - static dynamic fromJson(dynamic value, String targetType, {bool growable = false,}) { -+ upgradeDto(value, targetType); - try { - switch (targetType) { - case 'String': diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache index 254843e00eefa..9a7b1439b1fdf 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache +++ b/open-api/templates/mobile/serialization/native/native_class.mustache @@ -111,6 +111,7 @@ class {{{classname}}} { /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods static {{{classname}}}? fromJson(dynamic value) { + upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache.patch b/open-api/templates/mobile/serialization/native/native_class.mustache.patch index 02e07f933a30d..4ba65949665f8 100644 --- a/open-api/templates/mobile/serialization/native/native_class.mustache.patch +++ b/open-api/templates/mobile/serialization/native/native_class.mustache.patch @@ -1,5 +1,5 @@ ---- native_class.mustache 2023-08-31 23:09:59.584269162 +0200 -+++ native_class1.mustache 2023-08-31 22:59:53.633083270 +0200 +--- native_class.mustache 2024-09-19 11:41:07.855683995 -0400 ++++ native_class_temp.mustache 2024-09-19 11:41:57.113249395 -0400 @@ -91,14 +91,14 @@ {{/isDateTime}} {{#isNullable}} @@ -17,10 +17,14 @@ } {{/defaultValue}} {{/required}} -@@ -114,17 +114,6 @@ +@@ -111,20 +111,10 @@ + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static {{{classname}}}? fromJson(dynamic value) { ++ upgradeDto(value, "{{{classname}}}"); if (value is Map) { final json = value.cast(); - + - // Ensure that the map contains the required keys. - // Note 1: the values aren't checked for validity beyond being non-null. - // Note 2: this code is stripped in release mode! @@ -35,9 +39,9 @@ return {{{classname}}}( {{#vars}} {{#isDateTime}} -@@ -215,6 +204,10 @@ +@@ -215,6 +205,10 @@ ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} - : {{{datatypeWithEnum}}}.parse(json[r'{{{baseName}}}'].toString()), + : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), {{/isNumber}} + {{#isDouble}} + {{{name}}}: (mapValueOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}).toDouble(), @@ -46,7 +50,7 @@ {{^isNumber}} {{^isEnum}} {{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, -@@ -223,6 +216,7 @@ +@@ -223,6 +217,7 @@ {{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, {{/isEnum}} {{/isNumber}} diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 3516580bbbc04..2a393af592b8c 100644 --- a/open-api/typescript-sdk/.nvmrc +++ b/open-api/typescript-sdk/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 6d5b78ee9a588..1d2365e148bfb 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,18 +1,18 @@ { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.117.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.117.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "dev": true, "license": "MIT", "dependencies": { @@ -32,9 +32,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index afa5f4585810b..5eb42245f3886 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.117.0", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", @@ -19,7 +19,7 @@ "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "typescript": "^5.3.3" }, "repository": { @@ -28,6 +28,6 @@ "directory": "open-api/typescript-sdk" }, "volta": { - "node": "20.17.0" + "node": "20.18.0" } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 43777552c59bf..31d1a7d7ca0d3 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.114.0 + * 1.117.0 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -19,6 +19,7 @@ export type UserResponseDto = { email: string; id: string; name: string; + profileChangedAt: string; profileImagePath: string; }; export type ActivityResponseDto = { @@ -53,6 +54,7 @@ export type UserAdminResponseDto = { license: (UserLicense) | null; name: string; oauthId: string; + profileChangedAt: string; profileImagePath: string; quotaSizeInBytes: number | null; quotaUsageInBytes: number | null; @@ -364,7 +366,6 @@ export type AssetMediaCreateDto = { fileModifiedAt: string; isArchived?: boolean; isFavorite?: boolean; - isOffline?: boolean; isVisible?: boolean; livePhotoVideoId?: string; sidecarData?: Blob; @@ -395,6 +396,7 @@ export type AssetBulkUploadCheckResult = { action: Action; assetId?: string; id: string; + isTrashed?: boolean; reason?: Reason; }; export type AssetBulkUploadCheckResponseDto = { @@ -426,6 +428,7 @@ export type UpdateAssetDto = { isArchived?: boolean; isFavorite?: boolean; latitude?: number; + livePhotoVideoId?: string | null; longitude?: number; rating?: number; }; @@ -546,9 +549,12 @@ export type AllJobStatusResponseDto = { thumbnailGeneration: JobStatusDto; videoConversion: JobStatusDto; }; +export type JobCreateDto = { + name: ManualJobName; +}; export type JobCommandDto = { command: JobCommand; - force: boolean; + force?: boolean; }; export type LibraryResponseDto = { assetCount: number; @@ -572,10 +578,6 @@ export type UpdateLibraryDto = { importPaths?: string[]; name?: string; }; -export type ScanLibraryDto = { - refreshAllFiles?: boolean; - refreshModifiedFiles?: boolean; -}; export type LibraryStatsResponseDto = { photos: number; total: number; @@ -649,6 +651,9 @@ export type SystemConfigSmtpDto = { replyTo: string; transport: SystemConfigSmtpTransportDto; }; +export type TestEmailResponseDto = { + messageId: string; +}; export type OAuthConfigDto = { redirectUri: string; }; @@ -664,6 +669,7 @@ export type PartnerResponseDto = { id: string; inTimeline?: boolean; name: string; + profileChangedAt: string; profileImagePath: string; }; export type UpdatePartnerDto = { @@ -829,6 +835,39 @@ export type PlacesResponseDto = { longitude: number; name: string; }; +export type RandomSearchDto = { + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isArchived?: boolean; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + isVisible?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + model?: string | null; + personIds?: string[]; + size?: number; + state?: string | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + "type"?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; + withPeople?: boolean; + withStacked?: boolean; +}; export type SmartSearchDto = { city?: string | null; country?: string | null; @@ -878,6 +917,10 @@ export type ServerAboutResponseDto = { sourceCommit?: string; sourceRef?: string; sourceUrl?: string; + thirdPartyBugFeatureUrl?: string; + thirdPartyDocumentationUrl?: string; + thirdPartySourceUrl?: string; + thirdPartySupportUrl?: string; version: string; versionUrl: string; }; @@ -886,6 +929,8 @@ export type ServerConfigDto = { isInitialized: boolean; isOnboarded: boolean; loginPageMessage: string; + mapDarkStyleUrl: string; + mapLightStyleUrl: string; oauthButtonText: string; trashDays: number; userDeleteDelay: number; @@ -955,6 +1000,11 @@ export type ServerVersionResponseDto = { minor: number; patch: number; }; +export type ServerVersionHistoryResponseDto = { + createdAt: string; + id: string; + version: string; +}; export type SessionResponseDto = { createdAt: string; current: boolean; @@ -1058,14 +1108,16 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedImageDto = { + format: ImageFormat; + quality: number; + size: number; +}; export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; - previewFormat: ImageFormat; - previewSize: number; - quality: number; - thumbnailFormat: ImageFormat; - thumbnailSize: number; + preview: SystemConfigGeneratedImageDto; + thumbnail: SystemConfigGeneratedImageDto; }; export type JobSettingsDto = { concurrency: number; @@ -1238,6 +1290,9 @@ export type TimeBucketResponseDto = { count: number; timeBucket: string; }; +export type TrashResponseDto = { + count: number; +}; export type UserUpdateMeDto = { email?: string; name?: string; @@ -1247,6 +1302,7 @@ export type CreateProfileImageDto = { file: Blob; }; export type CreateProfileImageResponseDto = { + profileChangedAt: string; profileImagePath: string; userId: string; }; @@ -1684,6 +1740,9 @@ export function getMemoryLane({ day, month }: { ...opts })); } +/** + * This property was deprecated in v1.116.0 + */ export function getRandom({ count }: { count?: number; }, opts?: Oazapfts.RequestOpts) { @@ -1939,6 +1998,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function createJob({ jobCreateDto }: { + jobCreateDto: JobCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({ + ...opts, + method: "POST", + body: jobCreateDto + }))); +} export function sendJobCommand({ id, jobCommandDto }: { id: JobName; jobCommandDto: JobCommandDto; @@ -2003,24 +2071,14 @@ export function updateLibrary({ id, updateLibraryDto }: { body: updateLibraryDto }))); } -export function removeOfflineFiles({ id }: { +export function scanLibrary({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/removeOffline`, { + return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, { ...opts, method: "POST" })); } -export function scanLibrary({ id, scanLibraryDto }: { - id: string; - scanLibraryDto: ScanLibraryDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/libraries/${encodeURIComponent(id)}/scan`, oazapfts.json({ - ...opts, - method: "POST", - body: scanLibraryDto - }))); -} export function getLibraryStatistics({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -2080,20 +2138,6 @@ export function reverseGeocode({ lat, lon }: { ...opts })); } -export function getMapStyle({ key, theme }: { - key?: string; - theme: MapTheme; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: object; - }>(`/map/style.json${QS.query(QS.explode({ - key, - theme - }))}`, { - ...opts - })); -} export function searchMemories(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2174,7 +2218,10 @@ export function addMemoryAssets({ id, bulkIdsDto }: { export function sendTestEmail({ systemConfigSmtpDto }: { systemConfigSmtpDto: SystemConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/notifications/test-email", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TestEmailResponseDto; + }>("/notifications/test-email", oazapfts.json({ ...opts, method: "POST", body: systemConfigSmtpDto @@ -2337,19 +2384,6 @@ export function updatePerson({ id, personUpdateDto }: { body: personUpdateDto }))); } -/** - * This property was deprecated in v1.113.0 - */ -export function getPersonAssets({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetResponseDto[]; - }>(`/people/${encodeURIComponent(id)}/assets`, { - ...opts - })); -} export function mergePerson({ id, mergePersonDto }: { id: string; mergePersonDto: MergePersonDto; @@ -2479,6 +2513,18 @@ export function searchPlaces({ name }: { ...opts })); } +export function searchRandom({ randomSearchDto }: { + randomSearchDto: RandomSearchDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>("/search/random", oazapfts.json({ + ...opts, + method: "POST", + body: randomSearchDto + }))); +} export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { @@ -2613,6 +2659,14 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function getVersionHistory(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerVersionHistoryResponseDto[]; + }>("/server/version-history", { + ...opts + })); +} export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/sessions", { ...opts, @@ -3055,13 +3109,19 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key })); } export function emptyTrash(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/trash/empty", { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TrashResponseDto; + }>("/trash/empty", { ...opts, method: "POST" })); } export function restoreTrash(opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/trash/restore", { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TrashResponseDto; + }>("/trash/restore", { ...opts, method: "POST" })); @@ -3069,7 +3129,10 @@ export function restoreTrash(opts?: Oazapfts.RequestOpts) { export function restoreAssets({ bulkIdsDto }: { bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/trash/restore/assets", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TrashResponseDto; + }>("/trash/restore/assets", oazapfts.json({ ...opts, method: "POST", body: bulkIdsDto @@ -3350,8 +3413,9 @@ export enum Reason { UnsupportedFormat = "unsupported-format" } export enum AssetJobName { - RegenerateThumbnail = "regenerate-thumbnail", + RefreshFaces = "refresh-faces", RefreshMetadata = "refresh-metadata", + RegenerateThumbnail = "regenerate-thumbnail", TranscodeVideo = "transcode-video" } export enum AssetMediaSize { @@ -3362,6 +3426,11 @@ export enum EntityType { Asset = "ASSET", Album = "ALBUM" } +export enum ManualJobName { + PersonCleanup = "person-cleanup", + TagCleanup = "tag-cleanup", + UserCleanup = "user-cleanup" +} export enum JobName { ThumbnailGeneration = "thumbnailGeneration", MetadataExtraction = "metadataExtraction", @@ -3385,10 +3454,6 @@ export enum JobCommand { Empty = "empty", ClearFailed = "clear-failed" } -export enum MapTheme { - Light = "light", - Dark = "dark" -} export enum MemoryType { OnThisDay = "on_this_day" } diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index d872b8435b058..51ea8238dabb0 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -1,84 +1,90 @@ -

-
+

- +

-

Immich - Solução self-hosted de alta performance para backup de fotos e vídeos

+

Solução self-hosted de alta performance para backup de fotos e vídeos


- +

- English - Català - Español - Français - Italiano - 日本語 - 한국어 - Deutsch - Nederlands - Türkçe - 中文 - Русский - Svenska - العربية + +English +Català +Español +Français +Italiano +日本語 +한국어 +Deutsch +Nederlands +Türkçe +中文 +Русский +Svenska +العربية +Tiếng Việt +

## Avisos - ⚠️ Este projeto está sob **desenvolvimento constante**. -- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a compatibilidade com versões anteriores). -- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e vídeos.** -- ⚠️ Sempre siga o plano [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup para as suas mídias preciosas! +- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a + compatibilidade com versões anteriores). +- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e + vídeos.** +- ⚠️ Sempre siga o plano + [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup + para as suas mídias preciosas! -## Conteúdo +> [!NOTE] +> Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/. -- [Documentação Oficial](https://immich.app/docs) -- [Roadmap](https://github.com/orgs/immich-app/projects/1) -- [Demonstração](#demo) -- [Recursos](#features) -- [Introdução](https://immich.app/docs/overview/introduction) +## Links + +- [Documentação](https://immich.app/docs) +- [Sobre](https://immich.app/docs/overview/introduction) - [Instalação](https://immich.app/docs/install/requirements) +- [Roadmap](https://github.com/orgs/immich-app/projects/1) +- [Demonstração](#demonstração) +- [Funcionalidades](#funcionalidades) +- [Traduções](https://immich.app/docs/developer/translations) - [Diretrizes de Contribuição](https://immich.app/docs/overview/support-the-project) -## Documentação - -Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/. - ## Demonstração -Você pode acessar a demonstração web em https://demo.immich.app +Acesse a demonstração [aqui](https://demo.immich.app). A demonstração está +hospedada no Nível Gratuito da Oracle VM em Amsterdam com um processador 2.4Ghz +quad-core ARM64 e 24GB de RAM. -No aplicativo para dispositivos móveis, você pode usar `https://demo.immich.app/api` no campo `Server Endpoint URL` +No aplicativo para dispositivos móveis, você pode usar +`https://demo.immich.app/api` no campo `Server Endpoint URL` -```bash title="Credenciais de Demonstração" -Credenciais de Demonstração -email: demo@immich.app -senha: demo -``` +### Credenciais de login -``` -Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` +| Email | Senha | +| --------------- | ----- | +| demo@immich.app | demo | ## Atividades -![Atividades](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de Analytics do Repobeats") -## Recursos +![Atividades](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de Analytics do Repobeats") +## Funcionalidades -| Recursos | Aplicativo Móvel | Web | -|:----------------------------------------------------|------------------|-----| +| Funcionalidades | Aplicativo Móvel | Web | +| :-------------------------------------------------- | ---------------- | --- | | Fazer upload e visualizar fotos e vídeos | Sim | Sim | | Backup automático ao abrir o aplicativo | Sim | N/A | | Prevenir a duplicação de arquivos | Sim | Sim | @@ -88,17 +94,17 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR | Criação de álbuns e álbuns compartilhados | Sim | Sim | | Barra de rolagem arrastável | Sim | Sim | | Suporta formatos RAW | Sim | Sim | -| Visualização de metadados (EXIF, map) | Sim | Sim | -| Pesquisar por metadados, objetos, rostos, and CLIP | Sim | Sim | +| Visualização de metadados (EXIF, mapa) | Sim | Sim | +| Pesquisar por metadados, objetos, rostos, e CLIP | Sim | Sim | | Funções administrativas (gerenciamento de usuários) | Não | Sim | | Backup em segundo plano | Sim | N/A | -| Virtual scroll | Sim | Sim | +| Rolagem virtual | Sim | Sim | | Suporte OAuth | Sim | Sim | | Chaves de API | N/A | Sim | -| Backup e visualização de LivePhoto/MotionPhoto | Sim | Sim | +| Backup e reprodução de LivePhoto/MotionPhoto | Sim | Sim | | Visualização de imagens 360º | Não | Sim | | Estrutura de armazenamento definida pelo usuário | Sim | Sim | -| Compartilhar com o público | Não | Sim | +| Compartilhar com o público | Sim | Sim | | Arquivo e Favoritos | Sim | Sim | | Mapa Global | Sim | Sim | | Compartilhamento com parceiro | Sim | Sim | @@ -108,6 +114,29 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR | Galeria em modo apenas leitura | Sim | Sim | | Empilhamento de fotos | Sim | Sim | +## Traduções + +Leia mais sobre as traduções +[aqui](https://immich.app/docs/developer/translations). + + +Status da tradução + + +## Atividade do repositório + +![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de análise de atividade Repobeats") + +## Histórico de estrelas + + + + + + Gráfico de histórico de estrelas + + + ## Contribuidores diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md new file mode 100644 index 0000000000000..7ec4b9c948a2a --- /dev/null +++ b/readme_i18n/README_vi_VN.md @@ -0,0 +1,133 @@ +

+
+
Giấy phép: AGPLv3 + + Discord + +
+
+

+ +

+ +

+

Giải pháp quản lý ảnh và video tự lưu trữ hiệu suất cao

+
+ + + +
+

+ +English +Català +Español +Français +Italiano +日本語 +한국어 +Deutsch +Nederlands +Türkçe +中文 +Русский +Português Brasileiro +Svenska +العربية +Tiếng Việt + +

+ +## Tuyên bố miễn trừ trách nhiệm + +- ⚠️ Dự án đang được phát triển **rất tích cực**. +- ⚠️ Dự kiến ​​sẽ có lỗi và thay đổi đột ngột. +- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.** +- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn! + +> [!NOTE] +> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/. + +## Liên kết + +- [Tài liệu](https://immich.app/docs) +- [Giới thiệu](https://immich.app/docs/overview/introduction) +- [Cài đặt](https://immich.app/docs/install/requirements) +- [Lộ trình](https://immich.app/roadmap) +- [Demo](#demo) +- [Tính năng](#Tính-năng) +- [Dịch thuật](https://immich.app/docs/developer/translations) +- [Đóng góp](https://immich.app/docs/overview/support-the-project) + +## Demo + +Truy cập bản demo [tại đây](https://demo.immich.app). Bản demo đang chạy trên máy ảo Oracle Free-tier ở Amsterdam với CPU ARM64 lõi tứ 2,4 GHz và RAM 24 GB. + +Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app/api` cho `Server Endpoint URL` + +### Thông tin đăng nhập + +| Email | Mật khẩu | +| --------------- | -------- | +| demo@immich.app | demo | + +## Tính năng + +| Tính năng | Mobile | Web | +| :--------------------------------------------------- | ------ | ----- | +| Tải lên và xem video, ảnh | Có | Có | +| Tự động sao lưu khi ứng dụng được mở | Có | N/A | +| Ngăn chặn sự trùng lặp nội dung | Có | Có | +| Album được chọn để sao lưu | Có | N/A | +| Tải ảnh và video xuống thiết bị cục bộ | Có | Có | +| Hỗ trợ nhiều người dùng | Có | Có | +| Album và Album được chia sẻ | Có | Có | +| Thanh cuộn có thể chà / kéo | Có | Có | +| Hỗ trợ định dạng raw | Có | Có | +| Xem metadata (EXIF, bản đồ) | Có | Có | +| Tìm kiếm theo metadata, đối tượng, khuôn mặt và CLIP | Có | Có | +| Chức năng quản trị (quản lý người dùng) | Không | Có | +| Sao lưu trong nền | Có | N/A | +| Cuộn ảo | Có | Có | +| Hỗ trợ OAuth | Có | Có | +| API Keys | N/A | Có | +| Sao lưu và phát lại Live Photo/Motion Photo | Có | Có | +| Hỗ trợ hiển thị hình ảnh 360 độ | Không | Có | +| Cấu trúc lưu trữ do người dùng xác định | Có | Có | +| Chia sẻ công khai | Có | Có | +| Lưu trữ và Yêu thích | Có | Có | +| Bản đồ toàn cầu | Có | Có | +| Chia sẻ đối tác | Có | Có | +| Nhận dạng khuôn mặt và phân cụm | Có | Có | +| Kỷ niệm (x năm trước) | Có | Có | +| Hỗ trợ ngoại tuyến | Có | Không | +| Thư viện chỉ đọc | Có | Có | +| Ảnh xếp chồng | Có | Có | + +## Dịch thuật + +Đọc thêm về dịch thuật [tại đây](https://immich.app/docs/developer/translations). + + +Tình trạng dịch thuật + + +## Hoạt động của repository + +![Hoạt động](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Hình ảnh phân tích Repobeats") + +## Lịch sử Đánh dấu sao + + + + + + Biểu đồ Lịch sử Đánh dấu + + + +## Người đóng góp + + + + diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index d8a65fa6761c6..6355cd65ede21 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -57,7 +57,7 @@ - [路线图](https://immich.app/roadmap) - [在线演示](#示例) - [功能特性](#功能特性) -- [多语言](https://immich.app/docs/developer/tranlations) +- [多语言](https://immich.app/docs/developer/translations) - [贡献者](https://immich.app/docs/overview/support-the-project) ## 示例 @@ -95,7 +95,7 @@ | 实况照片备份和查看 | 是 | 是 | | 支持360度全景图显示 | 否 | 是 | | 用户自定义存储结构 | 是 | 是 | -| 公共分享 | 否 | 是 | +| 公共分享 | 是 | 是 | | 归档与收藏功能 | 是 | 是 | | 足迹地图 | 是 | 是 | | 好友分享 | 是 | 是 | diff --git a/server/.nvmrc b/server/.nvmrc index 3516580bbbc04..2a393af592b8c 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/server/Dockerfile b/server/Dockerfile index 04a495e090a53..19383e1b0fff5 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240903@sha256:ca18e2805ec8ddcf0ac7734a6eaf6d9a08bd3a14218bf0dbdbe865d83117190f AS dev +FROM ghcr.io/immich-app/base-server-dev:20241008@sha256:d1af54cfda17b6b653de580afdc4bdc5cb06153b269e402035ad485a4fe0262e AS dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build -FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 AS web +FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 AS web WORKDIR /usr/src/open-api/typescript-sdk COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ @@ -41,7 +41,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240903@sha256:d0d170ceeee7ef6c7b62b5d927820d74c14a9893f3e6285c1b9df45b33951b09 +FROM ghcr.io/immich-app/base-server-prod:20241008@sha256:c0cf2a16987a53d9c2f00f127415da537b5812055a6855a62e4b0abd33c4d695 WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/bin/immich-healthcheck b/server/bin/immich-healthcheck index 6043e526aa919..cf0accb8ddb32 100755 --- a/server/bin/immich-healthcheck +++ b/server/bin/immich-healthcheck @@ -1,3 +1,3 @@ #!/usr/bin/env bash -node /usr/src/app/dist/utils/healthcheck.js +node /usr/src/app/dist/bin/healthcheck.js diff --git a/server/package-lock.json b/server/package-lock.json index 51f038dfa0301..5e2c7fd662a3c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.114.0", + "version": "1.117.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.114.0", + "version": "1.117.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", @@ -20,11 +20,11 @@ "@nestjs/swagger": "^7.1.8", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.49.0", + "@opentelemetry/auto-instrumentations-node": "^0.50.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.53.0", "@opentelemetry/sdk-node": "^0.53.0", - "@react-email/components": "^0.0.24", + "@react-email/components": "^0.0.25", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -34,7 +34,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "^28.1.0", + "exiftool-vendored": "^28.3.1", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", @@ -83,9 +83,10 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/pngjs": "^6.0.5", "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", @@ -99,6 +100,7 @@ "eslint-plugin-unicorn": "^55.0.0", "globals": "^15.9.0", "mock-fs": "^5.2.0", + "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", "rimraf": "^6.0.0", @@ -733,9 +735,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -749,9 +751,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -765,9 +767,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -781,9 +783,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -797,9 +799,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -813,9 +815,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -829,9 +831,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -845,9 +847,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -861,9 +863,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -877,9 +879,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -893,9 +895,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -909,9 +911,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -925,9 +927,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -941,9 +943,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -957,9 +959,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -973,9 +975,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -989,9 +991,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -1005,9 +1007,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -1021,9 +1023,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -1037,9 +1039,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -1053,9 +1055,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -1069,9 +1071,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -1085,9 +1087,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -1138,6 +1140,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", @@ -1198,9 +1209,9 @@ "dev": true }, "node_modules/@eslint/js": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", - "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1215,6 +1226,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -1973,9 +1996,9 @@ } }, "node_modules/@nestjs/cli": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.4.tgz", - "integrity": "sha512-WKERbSZJGof0+9XeeMmWnb/9FpNxogcB5eTJTHjc9no0ymdTw3jTzT+KZL9iC/hGqBpuomDLaNFCYbAOt29nBw==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", + "integrity": "sha512-FP7Rh13u8aJbHe+zZ7hM0CC4785g9Pw4lz4r2TTgRtf0zTxSWMkJaPEwyjX8SK9oWK2GsYxl+fKpwVZNbmnj9A==", "dev": true, "dependencies": { "@angular-devkit/core": "17.3.8", @@ -1995,7 +2018,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.93.0", + "webpack": "5.94.0", "webpack-node-externals": "3.0.0" }, "bin": { @@ -2031,12 +2054,12 @@ } }, "node_modules/@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.4.tgz", + "integrity": "sha512-0j2/zqRw9nvHV1GKTktER8B/hIC/Z8CYFjN/ZqUuvwayCH+jZZBhCR2oRyuvLTXdnlSmtCAg2xvQ0ULqQvzqhA==", "dependencies": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2058,6 +2081,11 @@ } } }, + "node_modules/@nestjs/common/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/config": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.2.3.tgz", @@ -2073,16 +2101,16 @@ } }, "node_modules/@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "hasInstallScript": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" }, "funding": { @@ -2109,6 +2137,11 @@ } } }, + "node_modules/@nestjs/core/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/event-emitter": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz", @@ -2141,15 +2174,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "dependencies": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2160,13 +2193,18 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.4.tgz", + "integrity": "sha512-5GEYUA3sNbX2jOBP6FmrIK/zv9VCdvpdr4Sef1OKvt1U0qsV1YgmWPWDPumZM77n5DI0VHSJPyo7yjZaEKWOiQ==", "dependencies": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2178,10 +2216,15 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-socket.io/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "dependencies": { "cron": "3.1.7", "uuid": "10.0.0" @@ -2226,15 +2269,15 @@ "dev": true }, "node_modules/@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "dependencies": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" }, "peerDependencies": { @@ -2258,12 +2301,12 @@ } }, "node_modules/@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.4.tgz", + "integrity": "sha512-qRGFj51A5RM7JqA8pcyEwSLA3Y0dle/PAZ8oxP0suimoCusRY3Tk7wYqutZdCNj1ATb678SDaUZDHk2pwSv9/g==", "dev": true, "dependencies": { - "tslib": "2.6.3" + "tslib": "2.7.0" }, "funding": { "type": "opencollective", @@ -2284,6 +2327,12 @@ } } }, + "node_modules/@nestjs/testing/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -2300,13 +2349,13 @@ } }, "node_modules/@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.4.tgz", + "integrity": "sha512-ZHnak04i/iKBS0csjJa7K6D6xdsB0Yz6duJuCR7xGLItchFK+Ne21m9rEF8ffvW74U7UAYkQHBgD5242LBBYiQ==", "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" }, "peerDependencies": { "@nestjs/common": "^10.0.0", @@ -2321,6 +2370,11 @@ } } }, + "node_modules/@nestjs/websockets/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + }, "node_modules/@next/env": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", @@ -2524,9 +2578,9 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.0.tgz", - "integrity": "sha512-HxjD7xH9iAE4OyhNaaSec65i1H6QZYBWSwWkowFfsc5YAcDvJG30/J1sRKXEQqdmUcKTXEAnA66UciqZha/4+Q==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", + "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", "dependencies": { "@opentelemetry/api": "^1.0.0" }, @@ -2535,57 +2589,57 @@ } }, "node_modules/@opentelemetry/auto-instrumentations-node": { - "version": "0.49.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.49.2.tgz", - "integrity": "sha512-xtETEPmAby/3MMmedv8Z/873sdLTWg+Vq98rtm4wbwvAiXBB/ao8qRyzRlvR2MR6puEr+vIB/CXeyJnzNA3cyw==", - "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/instrumentation-amqplib": "^0.41.0", - "@opentelemetry/instrumentation-aws-lambda": "^0.43.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.43.1", - "@opentelemetry/instrumentation-bunyan": "^0.40.0", - "@opentelemetry/instrumentation-cassandra-driver": "^0.40.0", - "@opentelemetry/instrumentation-connect": "^0.38.0", - "@opentelemetry/instrumentation-cucumber": "^0.8.0", - "@opentelemetry/instrumentation-dataloader": "^0.11.0", - "@opentelemetry/instrumentation-dns": "^0.38.0", - "@opentelemetry/instrumentation-express": "^0.41.1", - "@opentelemetry/instrumentation-fastify": "^0.38.0", - "@opentelemetry/instrumentation-fs": "^0.14.0", - "@opentelemetry/instrumentation-generic-pool": "^0.38.1", - "@opentelemetry/instrumentation-graphql": "^0.42.0", - "@opentelemetry/instrumentation-grpc": "^0.52.0", - "@opentelemetry/instrumentation-hapi": "^0.40.0", - "@opentelemetry/instrumentation-http": "^0.52.0", - "@opentelemetry/instrumentation-ioredis": "^0.42.0", - "@opentelemetry/instrumentation-kafkajs": "^0.2.0", - "@opentelemetry/instrumentation-knex": "^0.39.0", - "@opentelemetry/instrumentation-koa": "^0.42.0", - "@opentelemetry/instrumentation-lru-memoizer": "^0.39.0", - "@opentelemetry/instrumentation-memcached": "^0.38.0", - "@opentelemetry/instrumentation-mongodb": "^0.46.0", - "@opentelemetry/instrumentation-mongoose": "^0.41.0", - "@opentelemetry/instrumentation-mysql": "^0.40.0", - "@opentelemetry/instrumentation-mysql2": "^0.40.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.39.0", - "@opentelemetry/instrumentation-net": "^0.38.0", - "@opentelemetry/instrumentation-pg": "^0.43.0", - "@opentelemetry/instrumentation-pino": "^0.41.0", - "@opentelemetry/instrumentation-redis": "^0.41.0", - "@opentelemetry/instrumentation-redis-4": "^0.41.1", - "@opentelemetry/instrumentation-restify": "^0.40.0", - "@opentelemetry/instrumentation-router": "^0.39.0", - "@opentelemetry/instrumentation-socket.io": "^0.41.0", - "@opentelemetry/instrumentation-tedious": "^0.13.0", - "@opentelemetry/instrumentation-undici": "^0.5.0", - "@opentelemetry/instrumentation-winston": "^0.39.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.0", - "@opentelemetry/resource-detector-aws": "^1.6.0", - "@opentelemetry/resource-detector-azure": "^0.2.10", - "@opentelemetry/resource-detector-container": "^0.4.0", - "@opentelemetry/resource-detector-gcp": "^0.29.10", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.50.0.tgz", + "integrity": "sha512-LqoSiWrOM4Cnr395frDHL4R/o5c2fuqqrqW8sZwhxvkasImmVlyL66YMPHllM2O5xVj2nP2ANUKHZSd293meZA==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/instrumentation-amqplib": "^0.42.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.44.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.44.0", + "@opentelemetry/instrumentation-bunyan": "^0.41.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.41.0", + "@opentelemetry/instrumentation-connect": "^0.39.0", + "@opentelemetry/instrumentation-cucumber": "^0.9.0", + "@opentelemetry/instrumentation-dataloader": "^0.12.0", + "@opentelemetry/instrumentation-dns": "^0.39.0", + "@opentelemetry/instrumentation-express": "^0.42.0", + "@opentelemetry/instrumentation-fastify": "^0.39.0", + "@opentelemetry/instrumentation-fs": "^0.15.0", + "@opentelemetry/instrumentation-generic-pool": "^0.39.0", + "@opentelemetry/instrumentation-graphql": "^0.43.0", + "@opentelemetry/instrumentation-grpc": "^0.53.0", + "@opentelemetry/instrumentation-hapi": "^0.41.0", + "@opentelemetry/instrumentation-http": "^0.53.0", + "@opentelemetry/instrumentation-ioredis": "^0.43.0", + "@opentelemetry/instrumentation-kafkajs": "^0.3.0", + "@opentelemetry/instrumentation-knex": "^0.40.0", + "@opentelemetry/instrumentation-koa": "^0.43.0", + "@opentelemetry/instrumentation-lru-memoizer": "^0.40.0", + "@opentelemetry/instrumentation-memcached": "^0.39.0", + "@opentelemetry/instrumentation-mongodb": "^0.47.0", + "@opentelemetry/instrumentation-mongoose": "^0.42.0", + "@opentelemetry/instrumentation-mysql": "^0.41.0", + "@opentelemetry/instrumentation-mysql2": "^0.41.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.40.0", + "@opentelemetry/instrumentation-net": "^0.39.0", + "@opentelemetry/instrumentation-pg": "^0.44.0", + "@opentelemetry/instrumentation-pino": "^0.42.0", + "@opentelemetry/instrumentation-redis": "^0.42.0", + "@opentelemetry/instrumentation-redis-4": "^0.42.0", + "@opentelemetry/instrumentation-restify": "^0.41.0", + "@opentelemetry/instrumentation-router": "^0.40.0", + "@opentelemetry/instrumentation-socket.io": "^0.42.0", + "@opentelemetry/instrumentation-tedious": "^0.14.0", + "@opentelemetry/instrumentation-undici": "^0.6.0", + "@opentelemetry/instrumentation-winston": "^0.40.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.1", + "@opentelemetry/resource-detector-aws": "^1.6.1", + "@opentelemetry/resource-detector-azure": "^0.2.11", + "@opentelemetry/resource-detector-container": "^0.4.1", + "@opentelemetry/resource-detector-gcp": "^0.29.11", "@opentelemetry/resources": "^1.24.0", - "@opentelemetry/sdk-node": "^0.52.0" + "@opentelemetry/sdk-node": "^0.53.0" }, "engines": { "node": ">=14" @@ -2594,95 +2648,6 @@ "@opentelemetry/api": "^1.4.1" } }, - "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/instrumentation": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz", - "integrity": "sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==", - "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/sdk-node": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.52.1.tgz", - "integrity": "sha512-uEG+gtEr6eKd8CVWeKMhH2olcCHM9dEK68pe0qE0be32BcCRsvYURhHaD1Srngh1SQcnQzZ4TP324euxqtBOJA==", - "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/exporter-trace-otlp-grpc": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", - "@opentelemetry/exporter-trace-otlp-proto": "0.52.1", - "@opentelemetry/exporter-zipkin": "1.25.1", - "@opentelemetry/instrumentation": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/sdk-trace-node": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/auto-instrumentations-node/node_modules/import-in-the-middle": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", - "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", - "dependencies": { - "acorn": "^8.8.2", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - }, "node_modules/@opentelemetry/context-async-hooks": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.26.0.tgz", @@ -2695,11 +2660,11 @@ } }, "node_modules/@opentelemetry/core": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.0.tgz", - "integrity": "sha512-n0B3s8rrqGrasTgNkXLKXzN0fXo+6IYP7M5b7AMsrZM33f/y6DS6kJ0Btd7SespASWq8bgL3taLo0oe0vB52IQ==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", + "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -2726,31 +2691,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", @@ -2865,14 +2805,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/exporter-logs-otlp-http": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.53.0.tgz", @@ -2891,31 +2823,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", @@ -3013,14 +2920,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/exporter-logs-otlp-proto": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.53.0.tgz", @@ -3041,31 +2940,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", @@ -3163,14 +3037,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/exporter-prometheus": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.53.0.tgz", @@ -3187,20 +3053,6 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/resources": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", @@ -3231,234 +3083,66 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/host-metrics": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.35.1.tgz", + "integrity": "sha512-d49/Un/pzqUSsGLeO8PvrX2bLxVAORcaoL3nxjJCzGikXA6gjWXxGOfT8D4qePlgnocozppWszefMHoRFS2MsA==", + "dependencies": { + "@opentelemetry/sdk-metrics": "^1.8.0", + "systeminformation": "^5.21.20" + }, "engines": { "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.52.1.tgz", - "integrity": "sha512-pVkSH20crBwMTqB3nIN4jpQKUEoB0Z94drIHpYyEqs7UBr+I0cpYyOR3bqjA/UasQUMROb3GX8ZX4/9cVRqGBQ==", + "node_modules/@opentelemetry/instrumentation": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz", + "integrity": "sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==", "dependencies": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-grpc-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" + "@opentelemetry/api-logs": "0.53.0", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": "^1.0.0" + "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.42.0.tgz", + "integrity": "sha512-fiuU6OKsqHJiydHWgTRQ7MnIrJ2lEqsdgFtNIH4LbAUJl/5XmrIeoDzDnox+hfkgWK65jsleFuQDtYb5hW1koQ==", "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.52.1.tgz", - "integrity": "sha512-05HcNizx0BxcFKKnS5rwOV+2GevLTVIRA0tRgWYyw4yCgR53Ic/xk83toYKts7kbzcI+dswInUg/4s8oyA+tqg==", - "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.52.1.tgz", - "integrity": "sha512-pt6uX0noTQReHXNeEslQv7x311/F1gJzMnp1HD2qgypLRPbXDeMzzeTngRTUaUbP6hqWNtPxuLr4DEoZG+TcEQ==", - "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/exporter-zipkin": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.25.1.tgz", - "integrity": "sha512-RmOwSvkimg7ETwJbUOPTMhJm9A9bG1U8s7Zo3ajDh4zM7eYcycQ0dM7FbLD6NXWbI2yj7UY4q8BKinKYBQksyw==", - "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/host-metrics": { - "version": "0.35.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/host-metrics/-/host-metrics-0.35.1.tgz", - "integrity": "sha512-d49/Un/pzqUSsGLeO8PvrX2bLxVAORcaoL3nxjJCzGikXA6gjWXxGOfT8D4qePlgnocozppWszefMHoRFS2MsA==", - "dependencies": { - "@opentelemetry/sdk-metrics": "^1.8.0", - "systeminformation": "^5.21.20" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.0.tgz", - "integrity": "sha512-LPwSIrw+60cheWaXsfGL8stBap/AppKQJFE+qqRvzYrgttXFH2ofoIMxWadeqPTq4BYOXM/C7Bdh/T+B60xnlQ==", - "dependencies": { - "@opentelemetry/api-logs": "0.52.0", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.8.0", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.41.0.tgz", - "integrity": "sha512-00Oi6N20BxJVcqETjgNzCmVKN+I5bJH/61IlHiIWd00snj1FdgiIKlpE4hYVacTB2sjIBB3nTbHskttdZEE2eg==", - "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/instrumentation-aws-lambda": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.43.0.tgz", - "integrity": "sha512-pSxcWlsE/pCWQRIw92sV2C+LmKXelYkjkA7C5s39iPUi4pZ2lA1nIiw+1R/y2pDEhUHcaKkNyljQr3cx9ZpVlQ==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.44.0.tgz", + "integrity": "sha512-6vmr7FJIuopZzsQjEQTp4xWtF6kBp7DhD7pPIN8FN0dKUKyuVraABIpgWjMfelaUPy4rTYUGkYqPluhG0wx8Dw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/propagator-aws-xray": "^1.3.1", "@opentelemetry/resources": "^1.8.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/aws-lambda": "8.10.122" + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/aws-lambda": "8.10.143" }, "engines": { "node": ">=14" @@ -3468,14 +3152,14 @@ } }, "node_modules/@opentelemetry/instrumentation-aws-sdk": { - "version": "0.43.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.1.tgz", - "integrity": "sha512-qLT2cCniJ5W+6PFzKbksnoIQuq9pS83nmgaExfUwXVvlwi0ILc50dea0tWBHZMkdIDa/zZdcuFrJ7+fUcSnRow==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.44.0.tgz", + "integrity": "sha512-HIWFg4TDQsayceiikOnruMmyQ0SZYW6WiR+wknWwWVLHC3lHTCpAnqzp5V42ckArOdlwHZu2Jvq2GMSM4Myx3w==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/propagation-utils": "^0.30.10", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/propagation-utils": "^0.30.11", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3485,12 +3169,12 @@ } }, "node_modules/@opentelemetry/instrumentation-bunyan": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.40.0.tgz", - "integrity": "sha512-aZ4cXaGWwj79ZXSYrgFVsrDlE4mmf2wfvP9bViwRc0j75A6eN6GaHYHqufFGMTCqASQn5pIjjP+Bx+PWTGiofw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.41.0.tgz", + "integrity": "sha512-NoQS+gcwQ7pzb2PZFyra6bAxDAVXBMmpKxBblEuXJWirGrAksQllg9XTdmqhrwT/KxUYrbVca/lMams7e51ysg==", "dependencies": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/api-logs": "^0.53.0", + "@opentelemetry/instrumentation": "^0.53.0", "@types/bunyan": "1.8.9" }, "engines": { @@ -3501,12 +3185,12 @@ } }, "node_modules/@opentelemetry/instrumentation-cassandra-driver": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.40.0.tgz", - "integrity": "sha512-JxbM39JU7HxE9MTKKwi6y5Z3mokjZB2BjwfqYi4B3Y29YO3I42Z7eopG6qq06yWZc+nQli386UDQe0d9xKmw0A==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.41.0.tgz", + "integrity": "sha512-hvTNcC8qjCQEHZTLAlTmDptjsEGqCKpN+90hHH8Nn/GwilGr5TMSwGrlfstdJuZWyw8HAnRUed6bcjvmHHk2Xw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3516,13 +3200,13 @@ } }, "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.38.0.tgz", - "integrity": "sha512-2/nRnx3pjYEmdPIaBwtgtSviTKHWnDZN3R+TkRUnhIVrvBKVcq+I5B2rtd6mr6Fe9cHlZ9Ojcuh7pkNh/xdWWg==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.39.0.tgz", + "integrity": "sha512-pGBiKevLq7NNglMgqzmeKczF4XQMTOUOTkK8afRHMZMnrK3fcETyTH7lVaSozwiOM3Ws+SuEmXZT7DYrrhxGlg==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/connect": "3.4.36" }, "engines": { @@ -3533,12 +3217,12 @@ } }, "node_modules/@opentelemetry/instrumentation-cucumber": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.8.0.tgz", - "integrity": "sha512-ieTm4RBIlZt2brPwtX5aEZYtYnkyqhAVXJI9RIohiBVMe5DxiwCwt+2Exep/nDVqGPX8zRBZUl4AEw423OxJig==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.9.0.tgz", + "integrity": "sha512-4PQNFnIqnA2WM3ZHpr0xhZpHSqJ5xJ6ppTIzZC7wPqe+ZBpj41vG8B6ieqiPfq+im4QdqbYnzLb3rj48GDEN9g==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3548,11 +3232,11 @@ } }, "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.11.0.tgz", - "integrity": "sha512-27urJmwkH4KDaMJtEv1uy2S7Apk4XbN4AgWMdfMJbi7DnOduJmeuA+DpJCwXB72tEWXo89z5T3hUVJIDiSNmNw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.12.0.tgz", + "integrity": "sha512-pnPxatoFE0OXIZDQhL2okF//dmbiWFzcSc8pUg9TqofCLYZySSxDCgQc69CJBo5JnI3Gz1KP+mOjS4WAeRIH4g==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -3562,11 +3246,11 @@ } }, "node_modules/@opentelemetry/instrumentation-dns": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.38.0.tgz", - "integrity": "sha512-Um07I0TQXDWa+ZbEAKDFUxFH40dLtejtExDOMLNJ1CL8VmOmA71qx93Qi/QG4tGkiI1XWqr7gF/oiMCJ4m8buQ==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.39.0.tgz", + "integrity": "sha512-+iPzvXqVdJa67QBuz2tuP0UI3LS1/cMMo6dS7360DDtOQX+sQzkiN+mo3Omn4T6ZRhkTDw6c7uwsHBcmL31+1g==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "semver": "^7.5.4" }, "engines": { @@ -3577,13 +3261,13 @@ } }, "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.1.tgz", - "integrity": "sha512-uRx0V3LPGzjn2bxAnV8eUsDT82vT7NTwI0ezEuPMBOTOsnPpGhWdhcdNdhH80sM4TrWrOfXm9HGEdfWE3TRIww==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.42.0.tgz", + "integrity": "sha512-YNcy7ZfGnLsVEqGXQPT+S0G1AE46N21ORY7i7yUQyfhGAL4RBjnZUqefMI0NwqIl6nGbr1IpF0rZGoN8Q7x12Q==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3593,13 +3277,13 @@ } }, "node_modules/@opentelemetry/instrumentation-fastify": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.38.0.tgz", - "integrity": "sha512-HBVLpTSYpkQZ87/Df3N0gAw7VzYZV3n28THIBrJWfuqw3Or7UqdhnjeuMIPQ04BKk3aZc0cWn2naSQObbh5vXw==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.39.0.tgz", + "integrity": "sha512-SS9uSlKcsWZabhBp2szErkeuuBDgxOUlllwkS92dVaWRnMmwysPhcEgHKB8rUe3BHg/GnZC1eo1hbTZv4YhfoA==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3609,12 +3293,12 @@ } }, "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.14.0.tgz", - "integrity": "sha512-pVc8P5AgliC1DphyyBUgsxXlm2XaPH4BpYvt7rAZDMIqUpRk8gs19SioABtKqqxvFzg5jPtgJfJsdxq0Y+maLw==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.15.0.tgz", + "integrity": "sha512-JWVKdNLpu1skqZQA//jKOcKdJC66TWKqa2FUFq70rKohvaSq47pmXlnabNO+B/BvLfmidfiaN35XakT5RyMl2Q==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -3624,11 +3308,11 @@ } }, "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.38.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.38.1.tgz", - "integrity": "sha512-WvssuKCuavu/hlq661u82UWkc248cyI/sT+c2dEIj6yCk0BUkErY1D+9XOO+PmHdJNE+76i2NdcvQX5rJrOe/w==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.39.0.tgz", + "integrity": "sha512-y4v8Y+tSfRB3NNBvHjbjrn7rX/7sdARG7FuK6zR8PGb28CTa0kHpEGCJqvL9L8xkTNvTXo+lM36ajFGUaK1aNw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -3638,11 +3322,11 @@ } }, "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.42.0.tgz", - "integrity": "sha512-N8SOwoKL9KQSX7z3gOaw5UaTeVQcfDO1c21csVHnmnmGUoqsXbArK2B8VuwPWcv6/BC/i3io+xTo7QGRZ/z28Q==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.43.0.tgz", + "integrity": "sha512-aI3YMmC2McGd8KW5du1a2gBA0iOMOGLqg4s9YjzwbjFwjlmMNFSK1P3AIg374GWg823RPUGfVTIgZ/juk9CVOA==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -3652,12 +3336,12 @@ } }, "node_modules/@opentelemetry/instrumentation-grpc": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.52.0.tgz", - "integrity": "sha512-YYhA2pbhMWgF5Hp6eR7AHp1utzZQ3Y0VB8GIwd8zJoLtAuQRZa1N29DUtZ+t/pGRJF+xGPVI+vP+7ugHgeN0zQ==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.53.0.tgz", + "integrity": "sha512-Ss338T92yE1UCgr9zXSY3cPuaAy27uQw+wAC5IwsQKCXL5wwkiOgkd+2Ngksa9EGsgUEMwGeHi76bDdHFJ5Rrw==", "dependencies": { - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/instrumentation": "0.53.0", + "@opentelemetry/semantic-conventions": "1.27.0" }, "engines": { "node": ">=14" @@ -3667,13 +3351,13 @@ } }, "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.40.0.tgz", - "integrity": "sha512-8U/w7Ifumtd2bSN1OLaSwAAFhb9FyqWUki3lMMB0ds+1+HdSxYBe9aspEJEgvxAqOkrQnVniAPTEGf1pGM7SOw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.41.0.tgz", + "integrity": "sha512-jKDrxPNXDByPlYcMdZjNPYCvw0SQJjN+B1A+QH+sx+sAHsKSAf9hwFiJSrI6C4XdOls43V/f/fkp9ITkHhKFbQ==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3683,13 +3367,13 @@ } }, "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.52.0.tgz", - "integrity": "sha512-E6ywZuxTa4LnVXZGwL1oj3e2Eog1yIaNqa8KjKXoGkDNKte9/SjQnePXOmhQYI0A9nf0UyFbP9aKd+yHrkJXUA==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.53.0.tgz", + "integrity": "sha512-H74ErMeDuZfj7KgYCTOFGWF5W9AfaPnqLQQxeFq85+D29wwV2yqHbz2IKLYpkOh7EI6QwDEl7rZCIxjJLyc/CQ==", "dependencies": { - "@opentelemetry/core": "1.25.0", - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/instrumentation": "0.53.0", + "@opentelemetry/semantic-conventions": "1.27.0", "semver": "^7.5.2" }, "engines": { @@ -3700,13 +3384,13 @@ } }, "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.42.0.tgz", - "integrity": "sha512-P11H168EKvBB9TUSasNDOGJCSkpT44XgoM6d3gRIWAa9ghLpYhl0uRkS8//MqPzcJVHr3h3RmfXIpiYLjyIZTw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.43.0.tgz", + "integrity": "sha512-i3Dke/LdhZbiUAEImmRG3i7Dimm/BD7t8pDDzwepSvIQ6s2X6FPia7561gw+64w+nx0+G9X14D7rEfaMEmmjig==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3716,12 +3400,12 @@ } }, "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.2.0.tgz", - "integrity": "sha512-uKKmhEFd0zR280tJovuiBG7cfnNZT4kvVTvqtHPxQP7nOmRbJstCYHFH13YzjVcKjkmoArmxiSulmZmF7SLIlg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.3.0.tgz", + "integrity": "sha512-UnkZueYK1ise8FXQeKlpBd7YYUtC7mM8J0wzUSccEfc/G8UqHQqAzIyYCUOUPUKp8GsjLnWOOK/3hJc4owb7Jg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.24.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3731,12 +3415,12 @@ } }, "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.39.0.tgz", - "integrity": "sha512-lRwTqIKQecPWDkH1KEcAUcFhCaNssbKSpxf4sxRTAROCwrCEnYkjOuqJHV+q1/CApjMTaKu0Er4LBv/6bDpoxA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.40.0.tgz", + "integrity": "sha512-6jka2jfX8+fqjEbCn6hKWHVWe++mHrIkLQtaJqUkBt3ZBs2xn1+y0khxiDS0v/mNb0bIKDJWwtpKFfsQDM1Geg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3746,13 +3430,13 @@ } }, "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.42.0.tgz", - "integrity": "sha512-H1BEmnMhho8o8HuNRq5zEI4+SIHDIglNB7BPKohZyWG4fWNuR7yM4GTlR01Syq21vODAS7z5omblScJD/eZdKw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.43.0.tgz", + "integrity": "sha512-lDAhSnmoTIN6ELKmLJBplXzT/Jqs5jGZehuG22EdSMaTwgjMpxMDI1YtlKEhiWPWkrz5LUsd0aOO0ZRc9vn3AQ==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3762,11 +3446,11 @@ } }, "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.39.0.tgz", - "integrity": "sha512-eU1Wx1RRTR/2wYXFzH9gcpB8EPmhYlNDIUHzUXjyUE0CAXEJhBLkYNlzdaVCoQDw2neDqS+Woshiia6+emWK9A==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.40.0.tgz", + "integrity": "sha512-21xRwZsEdMPnROu/QsaOIODmzw59IYpGFmuC4aFWvMj6stA8+Ei1tX67nkarJttlNjoM94um0N4X26AD7ff54A==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -3776,12 +3460,12 @@ } }, "node_modules/@opentelemetry/instrumentation-memcached": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.38.0.tgz", - "integrity": "sha512-tPmyqQEZNyrvg6G+iItdlguQEcGzfE+bJkpQifmBXmWBnoS5oU3UxqtyYuXGL2zI9qQM5yMBHH4nRXWALzy7WA==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.39.0.tgz", + "integrity": "sha512-WfwvKAZ9I1qILRP5EUd88HQjwAAL+trXpCpozjBi4U6a0A07gB3fZ5PFAxbXemSjF5tHk9KVoROnqHvQ+zzFSQ==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/memcached": "^2.2.6" }, "engines": { @@ -3792,13 +3476,13 @@ } }, "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.46.0.tgz", - "integrity": "sha512-VF/MicZ5UOBiXrqBslzwxhN7TVqzu1/LN/QDpkskqM0Zm0aZ4CVRbUygL8d7lrjLn15x5kGIe8VsSphMfPJzlA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.47.0.tgz", + "integrity": "sha512-yqyXRx2SulEURjgOQyJzhCECSh5i1uM49NUaq9TqLd6fA7g26OahyJfsr9NE38HFqGRHpi4loyrnfYGdrsoVjQ==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/sdk-metrics": "^1.9.1", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3808,13 +3492,13 @@ } }, "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.41.0.tgz", - "integrity": "sha512-ivJg4QnnabFxxoI7K8D+in7hfikjte38sYzJB9v1641xJk9Esa7jM3hmbPB7lxwcgWJLVEDvfPwobt1if0tXxA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.42.0.tgz", + "integrity": "sha512-AnWv+RaR86uG3qNEMwt3plKX1ueRM7AspfszJYVkvkehiicC3bHQA6vWdb6Zvy5HAE14RyFbu9+2hUUjR2NSyg==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3824,13 +3508,13 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.40.0.tgz", - "integrity": "sha512-d7ja8yizsOCNMYIJt5PH/fKZXjb/mS48zLROO4BzZTtDfhNCl2UM/9VIomP2qkGIFVouSJrGr/T00EzY7bPtKA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.41.0.tgz", + "integrity": "sha512-jnvrV6BsQWyHS2qb2fkfbfSb1R/lmYwqEZITwufuRl37apTopswu9izc0b1CYRp/34tUG/4k/V39PND6eyiNvw==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/mysql": "2.15.22" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.26" }, "engines": { "node": ">=14" @@ -3840,12 +3524,12 @@ } }, "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.40.0.tgz", - "integrity": "sha512-0xfS1xcqUmY7WE1uWjlmI67Xg3QsSUlNT+AcXHeA4BDUPwZtWqF4ezIwLgpVZfHOnkAEheqGfNSWd1PIu3Wnfg==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.41.0.tgz", + "integrity": "sha512-REQB0x+IzVTpoNgVmy5b+UnH1/mDByrneimP6sbDHkp1j8QOl1HyWOrBH/6YWR0nrbU3l825Em5PlybjT3232g==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1" }, "engines": { @@ -3856,12 +3540,12 @@ } }, "node_modules/@opentelemetry/instrumentation-nestjs-core": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.39.0.tgz", - "integrity": "sha512-mewVhEXdikyvIZoMIUry8eb8l3HUjuQjSjVbmLVTt4NQi35tkpnHQrG9bTRBrl3403LoWZ2njMPJyg4l6HfKvA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.40.0.tgz", + "integrity": "sha512-WF1hCUed07vKmf5BzEkL0wSPinqJgH7kGzOjjMAiTGacofNXjb/y4KQ8loj2sNsh5C/NN7s1zxQuCgbWbVTGKg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3871,12 +3555,12 @@ } }, "node_modules/@opentelemetry/instrumentation-net": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.38.0.tgz", - "integrity": "sha512-stjow1PijcmUquSmRD/fSihm/H61DbjPlJuJhWUe7P22LFPjFhsrSeiB5vGj3vn+QGceNAs+kioUTzMGPbNxtg==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.39.0.tgz", + "integrity": "sha512-rixHoODfI/Cx1B0mH1BpxCT0bRSxktuBDrt9IvpT2KSEutK5hR0RsRdgdz/GKk+BQ4u+IG6godgMSGwNQCueEA==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3886,15 +3570,15 @@ } }, "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.43.0.tgz", - "integrity": "sha512-og23KLyoxdnAeFs1UWqzSonuCkePUzCX30keSYigIzJe/6WSYA8rnEI5lobcxPEzg+GcU06J7jzokuEHbjVJNw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.44.0.tgz", + "integrity": "sha512-oTWVyzKqXud1BYEGX1loo2o4k4vaU1elr3vPO8NZolrBtFvQ34nx4HgUaexUDuEog00qQt+MLR5gws/p+JXMLQ==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1", "@types/pg": "8.6.1", - "@types/pg-pool": "2.0.4" + "@types/pg-pool": "2.0.6" }, "engines": { "node": ">=14" @@ -3914,13 +3598,13 @@ } }, "node_modules/@opentelemetry/instrumentation-pino": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.41.0.tgz", - "integrity": "sha512-Kpv0fJRk/8iMzMk5Ue5BsUJfHkBJ2wQoIi/qduU1a1Wjx9GLj6J2G17PHjPK5mnZjPNzkFOXFADZMfgDioliQw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.42.0.tgz", + "integrity": "sha512-SoX6FzucBfTuFNMZjdurJhcYWq2ve8/LkhmyVLUW31HpIB45RF1JNum0u4MkGisosDmXlK4njomcgUovShI+WA==", "dependencies": { - "@opentelemetry/api-logs": "^0.52.0", + "@opentelemetry/api-logs": "^0.53.0", "@opentelemetry/core": "^1.25.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -3930,13 +3614,13 @@ } }, "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.41.0.tgz", - "integrity": "sha512-RJ1pwI3btykp67ts+5qZbaFSAAzacucwBet5/5EsKYtWBpHbWwV/qbGN/kIBzXg5WEZBhXLrR/RUq0EpEUpL3A==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.42.0.tgz", + "integrity": "sha512-jZBoqve0rEC51q0HuhjtZVq1DtUvJHzEJ3YKGvzGar2MU1J4Yt5+pQAQYh1W4jSoDyKeaI4hyeUdWM5N0c2lqA==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3946,13 +3630,13 @@ } }, "node_modules/@opentelemetry/instrumentation-redis-4": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.41.1.tgz", - "integrity": "sha512-UqJAbxraBk7s7pQTlFi5ND4sAUs4r/Ai7gsAVZTQDbHl2kSsOp7gpHcpIuN5dpcI2xnuhM2tkH4SmEhbrv2S6Q==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.42.0.tgz", + "integrity": "sha512-NaD+t2JNcOzX/Qa7kMy68JbmoVIV37fT/fJYzLKu2Wwd+0NCxt+K2OOsOakA8GVg8lSpFdbx4V/suzZZ2Pvdjg==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3962,13 +3646,13 @@ } }, "node_modules/@opentelemetry/instrumentation-restify": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.40.0.tgz", - "integrity": "sha512-sm/rH/GysY/KOEvZqYBZSLYFeXlBkHCgqPDgWc07tz+bHCN6mPs9P3otGOSTe7o3KAIM8Nc6ncCO59vL+jb2cA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.41.0.tgz", + "integrity": "sha512-gKEo+X/wVKUBuD2WDDlF7SlDNBHMWjSQoLxFCsGqeKgHR0MGtwMel8uaDGg9LJ83nKqYy+7Vl/cDFxjba6H+/w==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3978,12 +3662,12 @@ } }, "node_modules/@opentelemetry/instrumentation-router": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.39.0.tgz", - "integrity": "sha512-LaXnVmD69WPC4hNeLzKexCCS19hRLrUw3xicneAMkzJSzNJvPyk7G6I7lz7VjQh1cooObPBt9gNyd3hhTCUrag==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.40.0.tgz", + "integrity": "sha512-bRo4RaclGFiKtmv/N1D0MuzO7DuxbeqMkMCbPPng6mDwzpHAMpHz/K/IxJmF+H1Hi/NYXVjCKvHGClageLe9eA==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -3993,12 +3677,12 @@ } }, "node_modules/@opentelemetry/instrumentation-socket.io": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.41.0.tgz", - "integrity": "sha512-7fzDe9/FpO6NFizC/wnzXXX7bF9oRchsD//wFqy5g5hVEgXZCQ70IhxjrKdBvgjyIejR9T9zTvfQ6PfVKfkCAw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.42.0.tgz", + "integrity": "sha512-xB5tdsBzuZyicQTO3hDzJIpHQ7V1BYJ6vWPWgl19gWZDBdjEGc3HOupjkd3BUJyDoDhbMEHGk2nNlkUU99EfkA==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -4008,12 +3692,12 @@ } }, "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.13.0.tgz", - "integrity": "sha512-Pob0+0R62AqXH50pjazTeGBy/1+SK4CYpFUBV5t7xpbpeuQezkkgVGvLca84QqjBqQizcXedjpUJLgHQDixPQg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.14.0.tgz", + "integrity": "sha512-ofq7pPhSqvRDvD2FVx3RIWPj76wj4QubfrbqJtEx0A+fWoaYxJOCIQ92tYJh28elAmjMmgF/XaYuJuBhBv5J3A==", "dependencies": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/tedious": "^4.0.14" }, "engines": { @@ -4024,12 +3708,12 @@ } }, "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.5.0.tgz", - "integrity": "sha512-aNTeSrFAVcM9qco5DfZ9DNXu6hpMRe8Kt8nCDHfMWDB3pwgGVUE76jTdohc+H/7eLRqh4L7jqs5NSQoHw7S6ww==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.6.0.tgz", + "integrity": "sha512-ABJBhm5OdhGmbh0S/fOTE4N69IZ00CsHC5ijMYfzbw3E5NwLgpQk5xsljaECrJ8wz1SfXbO03FiSuu5AyRAkvQ==", "dependencies": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -4039,12 +3723,12 @@ } }, "node_modules/@opentelemetry/instrumentation-winston": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.39.0.tgz", - "integrity": "sha512-v/1xziLJ9CyB3YDjBSBzbB70Qd0JwWTo36EqWK5m3AR0CzsyMQQmf3ZIZM6sgx7hXMcRQ0pnEYhg6nhrUQPm9A==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.40.0.tgz", + "integrity": "sha512-eMk2tKl86YJ8/yHvtDbyhrE35/R0InhO9zuHTflPx8T0+IvKVUhPV71MsJr32sImftqeOww92QHt4Jd+a5db4g==", "dependencies": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/api-logs": "^0.53.0", + "@opentelemetry/instrumentation": "^0.53.0" }, "engines": { "node": ">=14" @@ -4053,14 +3737,10 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.52.1.tgz", - "integrity": "sha512-z175NXOtX5ihdlshtYBe5RpGeBoTXVCKPPLiQlD6FHvpM4Ch+p2B0yWKYSrBfLH24H9zjJiBdTrtD+hLlfnXEQ==", - "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-transformer": "0.52.1" - }, + "node_modules/@opentelemetry/propagation-utils": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.11.tgz", + "integrity": "sha512-rY4L/2LWNk5p/22zdunpqVmgz6uN419DsRTw5KFMa6u21tWhXS8devlMy4h8m8nnS20wM7r6yYweCNNKjgLYJw==", "engines": { "node": ">=14" }, @@ -4068,37 +3748,35 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", + "node_modules/@opentelemetry/propagator-aws-xray": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-aws-xray/-/propagator-aws-xray-1.3.1.tgz", + "integrity": "sha512-6fDMzFlt5r6VWv7MUd0eOpglXPFqykW8CnOuUxJ1VZyLy6mV1bzBlzpsqEmhx1bjvZYvH93vhGkQZqrm95mlrQ==", "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/core": "^1.0.0" }, "engines": { "node": ">=14" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", + "node_modules/@opentelemetry/redis-common": { + "version": "0.36.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", + "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==", "engines": { "node": ">=14" } }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.52.1.tgz", - "integrity": "sha512-zo/YrSDmKMjG+vPeA9aBBrsQM9Q/f2zo6N04WMB3yNldJRsgpRBeLLwvAt/Ba7dpehDLOEFBd1i2JCoaFtpCoQ==", + "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.1.tgz", + "integrity": "sha512-Qshebw6azBuKUqGkVgambZlLS6Xh+LCoLXep1oqW1RSzSOHQxGYDsPs99v8NzO65eJHHOu8wc2yKsWZQAgYsSw==", "dependencies": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1" + "@opentelemetry/resources": "^1.0.0", + "@opentelemetry/semantic-conventions": "^1.27.0" }, "engines": { "node": ">=14" @@ -4107,205 +3785,10 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.52.1.tgz", - "integrity": "sha512-I88uCZSZZtVa0XniRqQWKbjAUm73I8tpEy/uJYPPYw5d7BRdVk0RfTBQw8kSUl01oVWEuqxLDa802222MYyWHg==", - "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "protobufjs": "^7.3.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/propagation-utils": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.10.tgz", - "integrity": "sha512-hhTW8pFp9PSyosYzzuUL9rdm7HF97w3OCyElufFHyUnYnKkCBbu8ne2LyF/KSdI/xZ81ubxWZs78hX4S7pLq5g==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/propagator-aws-xray": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-aws-xray/-/propagator-aws-xray-1.3.1.tgz", - "integrity": "sha512-6fDMzFlt5r6VWv7MUd0eOpglXPFqykW8CnOuUxJ1VZyLy6mV1bzBlzpsqEmhx1bjvZYvH93vhGkQZqrm95mlrQ==", - "dependencies": { - "@opentelemetry/core": "^1.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/propagator-b3": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.25.1.tgz", - "integrity": "sha512-p6HFscpjrv7//kE+7L+3Vn00VEDUJB0n6ZrjkTYHrJ58QZ8B3ajSJhRbCcY6guQ3PDjTbxWklyvIN2ojVbIb1A==", - "dependencies": { - "@opentelemetry/core": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/propagator-jaeger": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.25.1.tgz", - "integrity": "sha512-nBprRf0+jlgxks78G/xq72PipVK+4or9Ypntw0gVZYNTCSK8rg5SeaGV19tV920CMqBD/9UIOiFr23Li/Q8tiA==", - "dependencies": { - "@opentelemetry/core": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.36.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", - "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.0.tgz", - "integrity": "sha512-cYL1DfBwszTQcpzjiezzFkZp1bzevXjaVJ+VClrufHzH17S0RADcaLRQcLq4GqbWCGfvkJKUqBNz6f1SgfePgw==", - "dependencies": { - "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/resource-detector-aws": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.6.1.tgz", - "integrity": "sha512-A/3lqx9xoew7sFi+AVUUVr6VgB7UJ5qqddkKR3gQk9hWLm1R7HUXVJG09cLcZ7DMNpX13DohPRGmHE/vp1vafw==", + "node_modules/@opentelemetry/resource-detector-aws": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-aws/-/resource-detector-aws-1.6.1.tgz", + "integrity": "sha512-A/3lqx9xoew7sFi+AVUUVr6VgB7UJ5qqddkKR3gQk9hWLm1R7HUXVJG09cLcZ7DMNpX13DohPRGmHE/vp1vafw==", "dependencies": { "@opentelemetry/core": "^1.0.0", "@opentelemetry/resources": "^1.10.0", @@ -4318,14 +3801,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/resource-detector-aws/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/resource-detector-azure": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-azure/-/resource-detector-azure-0.2.11.tgz", @@ -4342,28 +3817,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/resource-detector-azure/node_modules/@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resource-detector-azure/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/resource-detector-container": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-container/-/resource-detector-container-0.4.1.tgz", @@ -4379,22 +3832,14 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/resource-detector-container/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/resource-detector-gcp": { - "version": "0.29.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.10.tgz", - "integrity": "sha512-rm2HKJ9lsdoVvrbmkr9dkOzg3Uk0FksXNxvNBgrCprM1XhMoJwThI5i0h/5sJypISUAJlEeJS6gn6nROj/NpkQ==", + "version": "0.29.11", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.11.tgz", + "integrity": "sha512-07wJx4nyxD/c2z3n70OQOg8fmoO/baTsq8uU+f7tZaehRNQx76MPkRbV2L902N40Z21SPIG8biUZ30OXE9tOIg==", "dependencies": { "@opentelemetry/core": "^1.0.0", "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "gcp-metadata": "^6.0.0" }, "engines": { @@ -4441,55 +3886,6 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.52.1.tgz", - "integrity": "sha512-MBYh+WcPPsN8YpRHRmK1Hsca9pVlyyKd4BxOC4SsgHACnl/bPp4Cri9hWhVm5+2tiQ9Zf4qSc1Jshw9tOLGWQA==", - "dependencies": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/sdk-metrics": { "version": "1.25.1", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", @@ -4557,31 +3953,6 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/exporter-trace-otlp-grpc": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.53.0.tgz", @@ -4654,25 +4025,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/instrumentation": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz", - "integrity": "sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==", - "dependencies": { - "@opentelemetry/api-logs": "0.53.0", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-exporter-base": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", @@ -4834,7 +4186,7 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/semantic-conventions": { + "node_modules/@opentelemetry/semantic-conventions": { "version": "1.27.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", @@ -4842,115 +4194,6 @@ "node": ">=14" } }, - "node_modules/@opentelemetry/sdk-node/node_modules/import-in-the-middle": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", - "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", - "dependencies": { - "acorn": "^8.8.2", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz", - "integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==", - "dependencies": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.25.1.tgz", - "integrity": "sha512-nMcjFIKxnFqoez4gUmihdBrbpsEnAX/Xj16sGvZm+guceYE0NE00vLhpDVK6f3q8Q4VFI5xG8JjlXKMB/SkTTQ==", - "dependencies": { - "@opentelemetry/context-async-hooks": "1.25.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/propagator-b3": "1.25.1", - "@opentelemetry/propagator-jaeger": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "semver": "^7.5.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/context-async-hooks": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.25.1.tgz", - "integrity": "sha512-UW/ge9zjvAEmRWVapOP0qyCvPulWU6cQxGxDbWEFfGOj1VBBZAuOqTo3X6yWmDTD3Xe15ysCZChHncr2xFMIfQ==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.0.tgz", - "integrity": "sha512-M+kkXKRAIAiAP6qYyesfrC5TOmDpDVtsxuGfPcqd9B/iBrac+E14jYwrgm0yZBUIbIP2OnqC3j+UgkXLm1vxUQ==", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/sql-common": { "version": "0.40.1", "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", @@ -4966,9 +4209,9 @@ } }, "node_modules/@photostructure/tz-lookup": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz", - "integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA==" + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.0.0.tgz", + "integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw==" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -5070,9 +4313,9 @@ } }, "node_modules/@react-email/code-block": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.8.tgz", - "integrity": "sha512-WbuAEpTnB262i9C3SGPmmErgZ4iU5KIpqLUjr7uBJijqldLqZc5x39e8wPWaRdF7NLcShmrc/+G7GJgI1bdC5w==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.9.tgz", + "integrity": "sha512-Zrhc71VYrSC1fVXJuaViKoB/dBjxLw6nbE53Bm/eUuZPdnnZ1+ZUIh8jfaRKC5MzMjgnLGQTweGXVnfIrhyxtQ==", "dependencies": { "prismjs": "1.29.0" }, @@ -5106,13 +4349,13 @@ } }, "node_modules/@react-email/components": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.24.tgz", - "integrity": "sha512-/DNmfTREaT59UFdkHoIK3BewJ214LfRxmduiil3m7POj+gougkItANu1+BMmgbUATxjf7jH1WoBxo9x/rhFEFw==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.25.tgz", + "integrity": "sha512-lnfVVrThEcET5NPoeaXvrz9UxtWpGRcut2a07dLbyKgNbP7vj/cXTI5TuHtanCvhCddFpMDnElNRghDOfPzwUg==", "dependencies": { "@react-email/body": "0.0.10", "@react-email/button": "0.0.17", - "@react-email/code-block": "0.0.8", + "@react-email/code-block": "0.0.9", "@react-email/code-inline": "0.0.4", "@react-email/column": "0.0.12", "@react-email/container": "0.0.14", @@ -5350,9 +4593,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -5363,9 +4606,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -5376,9 +4619,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -5389,9 +4632,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -5402,9 +4645,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -5415,9 +4658,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -5428,9 +4671,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -5441,9 +4684,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -5454,9 +4697,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -5467,9 +4710,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -5480,9 +4723,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -5493,9 +4736,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -5506,9 +4749,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -5519,9 +4762,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -5532,9 +4775,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -5545,9 +4788,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -5614,9 +4857,9 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "node_modules/@swc/core": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.21.tgz", - "integrity": "sha512-7/cN0SZ+y2V6e0hsDD8koGR0QVh7Jl3r756bwaHLLSN+kReoUb/yVcLsA8iTn90JLME3DkQK4CPjxDCQiyMXNg==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.26.tgz", + "integrity": "sha512-f5uYFf+TmMQyYIoxkn/evWhNGuUzC730dFwAKGwBVHHVoPyak1/GvJUm6i1SKl+2Hrj9oN0i3WSoWWZ4pgI8lw==", "devOptional": true, "hasInstallScript": true, "dependencies": { @@ -5631,16 +4874,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.21", - "@swc/core-darwin-x64": "1.7.21", - "@swc/core-linux-arm-gnueabihf": "1.7.21", - "@swc/core-linux-arm64-gnu": "1.7.21", - "@swc/core-linux-arm64-musl": "1.7.21", - "@swc/core-linux-x64-gnu": "1.7.21", - "@swc/core-linux-x64-musl": "1.7.21", - "@swc/core-win32-arm64-msvc": "1.7.21", - "@swc/core-win32-ia32-msvc": "1.7.21", - "@swc/core-win32-x64-msvc": "1.7.21" + "@swc/core-darwin-arm64": "1.7.26", + "@swc/core-darwin-x64": "1.7.26", + "@swc/core-linux-arm-gnueabihf": "1.7.26", + "@swc/core-linux-arm64-gnu": "1.7.26", + "@swc/core-linux-arm64-musl": "1.7.26", + "@swc/core-linux-x64-gnu": "1.7.26", + "@swc/core-linux-x64-musl": "1.7.26", + "@swc/core-win32-arm64-msvc": "1.7.26", + "@swc/core-win32-ia32-msvc": "1.7.26", + "@swc/core-win32-x64-msvc": "1.7.26" }, "peerDependencies": { "@swc/helpers": "*" @@ -5652,9 +4895,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.21.tgz", - "integrity": "sha512-hh5uOZ7jWF66z2TRMhhXtWMQkssuPCSIZPy9VHf5KvZ46cX+5UeECDthchYklEVZQyy4Qr6oxfh4qff/5spoMA==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.26.tgz", + "integrity": "sha512-FF3CRYTg6a7ZVW4yT9mesxoVVZTrcSWtmZhxKCYJX9brH4CS/7PRPjAKNk6kzWgWuRoglP7hkjQcd6EpMcZEAw==", "cpu": [ "arm64" ], @@ -5668,9 +4911,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.21.tgz", - "integrity": "sha512-lTsPquqSierQ6jWiWM7NnYXXZGk9zx3NGkPLHjPbcH5BmyiauX0CC/YJYJx7YmS2InRLyALlGmidHkaF4JY28A==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.26.tgz", + "integrity": "sha512-az3cibZdsay2HNKmc4bjf62QVukuiMRh5sfM5kHR/JMTrLyS6vSw7Ihs3UTkZjUxkLTT8ro54LI6sV6sUQUbLQ==", "cpu": [ "x64" ], @@ -5684,9 +4927,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.21.tgz", - "integrity": "sha512-AgSd0fnSzAqCvWpzzZCq75z62JVGUkkXEOpfdi99jj/tryPy38KdXJtkVWJmufPXlRHokGTBitalk33WDJwsbA==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.26.tgz", + "integrity": "sha512-VYPFVJDO5zT5U3RpCdHE5v1gz4mmR8BfHecUZTmD2v1JeFY6fv9KArJUpjrHEEsjK/ucXkQFmJ0jaiWXmpOV9Q==", "cpu": [ "arm" ], @@ -5700,9 +4943,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.21.tgz", - "integrity": "sha512-l+jw6RQ4Y43/8dIst0c73uQE+W3kCWrCFqMqC/xIuE/iqHOnvYK6YbA1ffOct2dImkHzNiKuoehGqtQAc6cNaQ==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.26.tgz", + "integrity": "sha512-YKevOV7abpjcAzXrhsl+W48Z9mZvgoVs2eP5nY+uoMAdP2b3GxC0Df1Co0I90o2lkzO4jYBpTMcZlmUXLdXn+Q==", "cpu": [ "arm64" ], @@ -5716,9 +4959,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.21.tgz", - "integrity": "sha512-29KKZXrTo/c9F1JFL9WsNvCa6UCdIVhHP5EfuYhlKbn5/YmSsNFkuHdUtZFEd5U4+jiShXDmgGCtLW2d08LIwg==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.26.tgz", + "integrity": "sha512-3w8iZICMkQQON0uIcvz7+Q1MPOW6hJ4O5ETjA0LSP/tuKqx30hIniCGOgPDnv3UTMruLUnQbtBwVCZTBKR3Rkg==", "cpu": [ "arm64" ], @@ -5732,9 +4975,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.21.tgz", - "integrity": "sha512-HsP3JwddvQj5HvnjmOr+Bd5plEm6ccpfP5wUlm3hywzvdVkj+yR29bmD7UwpV/1zCQ60Ry35a7mXhKI6HQxFgw==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.26.tgz", + "integrity": "sha512-c+pp9Zkk2lqb06bNGkR2Looxrs7FtGDMA4/aHjZcCqATgp348hOKH5WPvNLBl+yPrISuWjbKDVn3NgAvfvpH4w==", "cpu": [ "x64" ], @@ -5748,9 +4991,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.21.tgz", - "integrity": "sha512-hYKLVeUTHqvFK628DFJEwxoX6p42T3HaQ4QjNtf3oKhiJWFh9iTRUrN/oCB5YI3R9WMkFkKh+99gZ/Dd0T5lsg==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.26.tgz", + "integrity": "sha512-PgtyfHBF6xG87dUSSdTJHwZ3/8vWZfNIXQV2GlwEpslrOkGqy+WaiiyE7Of7z9AvDILfBBBcJvJ/r8u980wAfQ==", "cpu": [ "x64" ], @@ -5764,9 +5007,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.21.tgz", - "integrity": "sha512-qyWAKW10aMBe6iUqeZ7NAJIswjfggVTUpDINpQGUJhz+pR71YZDidXgZXpaDB84YyDB2JAlRqd1YrLkl7CMiIw==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.26.tgz", + "integrity": "sha512-9TNXPIJqFynlAOrRD6tUQjMq7KApSklK3R/tXgIxc7Qx+lWu8hlDQ/kVPLpU7PWvMMwC/3hKBW+p5f+Tms1hmA==", "cpu": [ "arm64" ], @@ -5780,9 +5023,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.21.tgz", - "integrity": "sha512-cy61wS3wgH5mEwBiQ5w6/FnQrchBDAdPsSh0dKSzNmI+4K8hDxS8uzdBycWqJXO0cc+mA77SIlwZC3hP3Kum2g==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.26.tgz", + "integrity": "sha512-9YngxNcG3177GYdsTum4V98Re+TlCeJEP4kEwEg9EagT5s3YejYdKwVAkAsJszzkXuyRDdnHUpYbTrPG6FiXrQ==", "cpu": [ "ia32" ], @@ -5796,9 +5039,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.21.tgz", - "integrity": "sha512-/rexGItJURNJOdae+a48M+loT74nsEU+PyRRVAkZMKNRtLoYFAr0cpDlS5FodIgGunp/nqM0bst4H2w6Y05IKA==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.26.tgz", + "integrity": "sha512-VR+hzg9XqucgLjXxA13MtV5O3C0bK0ywtLIBw/+a+O+Oc6mxFWHtdUeXDbIi5AiPbn0fjgVJMqYnyjGyyX8u0w==", "cpu": [ "x64" ], @@ -5835,12 +5078,12 @@ } }, "node_modules/@testcontainers/postgresql": { - "version": "10.12.0", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.12.0.tgz", - "integrity": "sha512-n0Q0Btx0R923CDgm6KBXbesPH10CewpuuCPcnmEZzon3IneMzdk4UqVhhNNOeJFDGhtFrZBOoJ1o7CUI4J0vQw==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.13.2.tgz", + "integrity": "sha512-xd3u/rL8FrOBHFMu1aU+2d4sqPz9ffEb19ITtopT/tyBZWW9qCsgR6wSg0r2BJUd+2hT4UR5nR5cymi+ROkehw==", "dev": true, "dependencies": { - "testcontainers": "^10.12.0" + "testcontainers": "^10.13.2" } }, "node_modules/@tsconfig/node10": { @@ -5872,31 +5115,40 @@ "peer": true }, "node_modules/@turf/boolean-point-in-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", - "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.1.0.tgz", + "integrity": "sha512-mprVsyIQ+ijWTZwbnO4Jhxu94ZW2M2CheqLiRTsGJy0Ooay9v6Av5/Nl3/Gst7ZVXxPqMeMaFYkSzcTc87AKew==", "dependencies": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@turf/invariant": "^7.1.0", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.6.2" }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/helpers": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", - "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz", + "integrity": "sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" + }, "funding": { "url": "https://opencollective.com/turf" } }, "node_modules/@turf/invariant": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", - "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.1.0.tgz", + "integrity": "sha512-OCLNqkItBYIP1nE9lJGuIUatWGtQ4rhBKAyTfFu0z8npVzGEYzvguEeof8/6LkKmTTEHW53tCjoEhSSzdRh08Q==", "dependencies": { - "@turf/helpers": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" }, "funding": { "url": "https://opencollective.com/turf" @@ -5918,9 +5170,9 @@ "dev": true }, "node_modules/@types/aws-lambda": { - "version": "8.10.122", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.122.tgz", - "integrity": "sha512-vBkIh9AY22kVOCEKo5CJlyCgmSWvasC+SWUxL/x/vOwRobMpI/HG1xp/Ae3AqmSiZeLUbOhW0FCD3ZjqqUxmXw==" + "version": "8.10.143", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.143.tgz", + "integrity": "sha512-u5vzlcR14ge/4pMTTMDQr3MF0wEe38B2F9o84uC4F43vN5DGTy63npRrB6jQhyt+C0lGv4ZfiRcRkqJoZuPnmg==" }, "node_modules/@types/bcrypt": { "version": "5.0.2", @@ -6011,21 +5263,13 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", "dev": true, + "optional": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", - "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -6065,6 +5309,11 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" + }, "node_modules/@types/http-errors": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.2.tgz", @@ -6094,9 +5343,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "version": "4.17.10", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz", + "integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==", "dev": true }, "node_modules/@types/luxon": { @@ -6143,25 +5392,25 @@ } }, "node_modules/@types/mysql": { - "version": "2.15.22", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.22.tgz", - "integrity": "sha512-wK1pzsJVVAjYCSZWQoWHziQZbNggXFDUEIGf54g4ZM/ERuP86uGdWeKZWMYlqTPMZfHJJvLPyogXGvCOg87yLQ==", + "version": "2.15.26", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", + "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", "dependencies": { "@types/node": "*" } }, "node_modules/@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "dependencies": { "undici-types": "~6.19.2" } }, "node_modules/@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "dependencies": { "@types/node": "*" @@ -6174,9 +5423,9 @@ "dev": true }, "node_modules/@types/pg": { - "version": "8.10.9", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz", - "integrity": "sha512-UksbANNE/f8w0wOMxVKKIrLCbEMV+oM1uKejmwXr39olg4xqcfBDbXxObJAt6XxHbDa4XTKOlUEcEltXDX+XLQ==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -6184,23 +5433,23 @@ } }, "node_modules/@types/pg-pool": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.4.tgz", - "integrity": "sha512-qZAvkv1K3QbmHHFYSNRYPkRjOWRLBYrL4B9c+wG0GSVGBw0NtJwPcgx/DSddeDJvRGMHCEQ4VMEVfuJ/0gZ3XQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", "dependencies": { "@types/pg": "*" } }, "node_modules/@types/pg/node_modules/pg-types": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", - "integrity": "sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", - "postgres-date": "~2.0.1", + "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" }, @@ -6228,9 +5477,9 @@ } }, "node_modules/@types/pg/node_modules/postgres-date": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.0.1.tgz", - "integrity": "sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", "engines": { "node": ">=12" } @@ -6249,6 +5498,16 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -6268,9 +5527,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.4", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz", - "integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==", + "version": "18.3.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", "dev": true, "dependencies": { "@types/prop-types": "*", @@ -6387,16 +5646,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", - "integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/type-utils": "8.3.0", - "@typescript-eslint/utils": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -6420,15 +5679,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", - "integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4" }, "engines": { @@ -6448,13 +5707,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", - "integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6465,13 +5724,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", - "integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/utils": "8.3.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -6489,9 +5748,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", - "integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6502,13 +5761,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", - "integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -6554,15 +5813,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", - "integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0" + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6576,12 +5835,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", - "integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -6593,19 +5852,19 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.2.tgz", + "integrity": "sha512-b7kHrFrs2urS0cOk5N10lttI8UdJ/yP3nB4JYTREvR5o18cR99yPpK4gK8oQgI42BVv0ILWYUSYB7AXkAUDc0g==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -6615,7 +5874,13 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/coverage-v8/node_modules/magic-string": { @@ -6628,13 +5893,13 @@ } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.2.tgz", + "integrity": "sha512-FEgtlN8mIUSEAAnlvn7mP8vzaWhEaAEvhSXCqrsijM7K6QqjB11qoRZYEd4AKSCDz8p0/+yH5LzhZ47qt+EyPg==", "dev": true, "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -6642,10 +5907,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.2.tgz", + "integrity": "sha512-ExElkCGMS13JAJy+812fw1aCv2QO/LBK6CyO4WOPAzLTmve50gydOlWhgdBJPx2ztbADUq3JVI0C5U+bShaeEA==", + "dev": true, + "dependencies": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.2", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.2.tgz", + "integrity": "sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==", "dev": true, "dependencies": { "tinyrainbow": "^1.2.0" @@ -6655,12 +5956,12 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.2.tgz", + "integrity": "sha512-UCsPtvluHO3u7jdoONGjOSil+uON5SSvU9buQh3lP7GgUXHp78guN1wRmZDX4wGK6J10f9NUtP6pO+SFquoMlw==", "dev": true, "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -6668,13 +5969,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.2.tgz", + "integrity": "sha512-xtAeNsZ++aRIYIUsek7VHzry/9AcxeULlegBvsdLncLmNCR6tR8SRjn8BbDP4naxtccvzTqZ+L1ltZlRCfBZFA==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.2", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -6691,9 +5992,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.2.tgz", + "integrity": "sha512-GSUi5zoy+abNRJwmFhBDC0yRuVUn8WMlQscvnbbXdKLXX9dE59YbfwXxuJ/mth6eeqIzofU8BB5XDo/Ns/qK2A==", "dev": true, "dependencies": { "tinyspy": "^3.0.0" @@ -6703,13 +6004,12 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.2.tgz", + "integrity": "sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==", "dev": true, "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -7417,9 +6717,9 @@ } }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -7429,7 +6729,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -7798,9 +7098,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==" }, "node_modules/class-transformer": { "version": "0.5.1", @@ -8373,11 +7673,11 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -8723,9 +8023,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -8740,9 +8040,9 @@ } }, "node_modules/engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "dependencies": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -8753,7 +8053,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" }, "engines": { "node": ">=10.2.0" @@ -8768,9 +8068,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", - "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -8825,9 +8125,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -8837,29 +8137,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { @@ -8888,19 +8188,23 @@ } }, "node_modules/eslint": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz", - "integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.9.1", + "@eslint/js": "9.11.1", + "@eslint/plugin-kit": "^0.2.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -8920,7 +8224,6 @@ "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", @@ -9049,6 +8352,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, "node_modules/eslint/node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -9066,9 +8375,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9217,133 +8526,72 @@ "node": ">=0.8.x" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/execa/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/exiftool-vendored": { - "version": "28.2.1", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", - "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", + "version": "28.3.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.3.1.tgz", + "integrity": "sha512-S2LNaGNu4wBv6q0f/lvst+6DhQrYgc27oDsTgRvx8dGK/5Z1MK4PyMfKCb5GCeCr/nSTGsRnoJlxxRhO1YkBsA==", + "license": "MIT", "dependencies": { - "@photostructure/tz-lookup": "^10.0.0", + "@photostructure/tz-lookup": "^11.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", "he": "^1.2.0", "luxon": "^3.5.0" }, "optionalDependencies": { - "exiftool-vendored.exe": "12.91.0", - "exiftool-vendored.pl": "12.91.0" + "exiftool-vendored.exe": "12.96.0", + "exiftool-vendored.pl": "12.96.0" } }, "node_modules/exiftool-vendored.exe": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.91.0.tgz", - "integrity": "sha512-nxcoGBaJL/D+Wb0jVe8qwyV8QZpRcCzU0aCKhG0S1XNGWGjJJJ4QV851aobcfDwI4NluFOdqkjTSf32pVijvHg==", + "version": "12.96.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.96.0.tgz", + "integrity": "sha512-pKPN9F/Evw2yyO5/+ml3spbXIqejzOxyF7jEnj8tLU2JPSmIlziPUZ75XIhcPbilX86jVKmuiso7FUDicOg8pQ==", "optional": true, "os": [ "win32" ] }, "node_modules/exiftool-vendored.pl": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.91.0.tgz", - "integrity": "sha512-GZMy9+Jiv8/C7R4uYe1kWtXsAaJdgVezTwYa+wDeoqvReHiX2t5uzkCrzWdjo4LGl5mPQkyKhN7/uPLYk5Ak6w==", + "version": "12.96.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.96.0.tgz", + "integrity": "sha512-v4nGnovAMBsTfOWhwAcOiRiq/8kuJOo3GUMHNpug7Mr4jLz3tmWEo7DdNyOYmpcvWbA6smOTG0SmwsrY8fsW+A==", "optional": true, "os": [ "!win32" ] }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -9376,9 +8624,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/extend": { "version": "3.0.2", @@ -9509,12 +8757,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -9821,17 +9069,17 @@ } }, "node_modules/geo-tz": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.0.2.tgz", - "integrity": "sha512-NjEzJBzaMhO9C7lFZIsWDkVED7aLxcES3iEZOWJ97dhnDUGhEB8vhW7MaWR+2y4aWvtFV/VyuDi8Y0rUHvm4tw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.1.1.tgz", + "integrity": "sha512-V6FEJ9UQOHnBD7eAOkJG3gZlc0LjKckRjt56cit6MLKMPF2qQIUBDYb0iUj4kw8l+WUeAu9ytPdSPpcLEELWtw==", "dependencies": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", + "@turf/boolean-point-in-polygon": "^7.1.0", + "@turf/helpers": "^7.1.0", "geobuf": "^3.0.2", "pbf": "^3.2.1" }, "engines": { - "node": ">=12" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/evansiroky" @@ -9860,15 +9108,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -9911,18 +9150,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", @@ -10002,11 +9229,10 @@ } }, "node_modules/globals": { - "version": "15.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", - "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "version": "15.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.10.0.tgz", + "integrity": "sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -10235,19 +9461,10 @@ "node": ">= 6" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/i18n-iso-countries": { - "version": "7.11.3", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.3.tgz", - "integrity": "sha512-yxQVzNvxEaspSqNnCbqLvwTZNXXkGydWcSxytJYZYb0KH5pn13fdywuX0vFxmOg57Z8ff416AuKDx6Oqnx+j9w==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.12.0.tgz", + "integrity": "sha512-NDFf5j/raA5JrcPT/NcHP3RUMH7TkdkxQKAKdvDlgb+MS296WJzzqvV0Y5uwavSm7A6oYvBeSV0AxoHdDiHIiw==", "dependencies": { "diacritics": "1.3.0" }, @@ -10310,9 +9527,9 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.8.0.tgz", - "integrity": "sha512-/xQjze8szLNnJ5rvHSzn+dcVXqCAU6Plbk4P24U/jwPmg1wy7IIp9OjKIO5tYue8GSPhDpPDiApQjvBUmWwhsQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", + "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", "dependencies": { "acorn": "^8.8.2", "acorn-import-attributes": "^1.9.5", @@ -10651,9 +9868,9 @@ } }, "node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -10973,13 +10190,10 @@ } }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", - "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -11092,9 +10306,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-stream": { "version": "2.0.0", @@ -11119,11 +10336,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -11261,9 +10478,9 @@ "dev": true }, "node_modules/mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.3.0.tgz", + "integrity": "sha512-IMvz1X+RF7vf+ur7qUenXMR7/FSKSIqS3HqFHXcyNI7G0FbpFO8L5lfsUJhl+bhK1AiulVHWKUSxebWauPA+xQ==", "dev": true, "engines": { "node": ">=12.0.0" @@ -11289,9 +10506,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/msgpackr": { "version": "1.10.1", @@ -11656,9 +10873,9 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, "node_modules/nodemailer": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", - "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", "engines": { "node": ">=6.0.0" } @@ -11711,33 +10928,6 @@ "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -11766,9 +10956,12 @@ } }, "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -11828,11 +11021,11 @@ } }, "node_modules/openid-client": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", - "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", "dependencies": { - "jose": "^4.15.5", + "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" @@ -12085,9 +11278,9 @@ } }, "node_modules/path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "node_modules/path-type": { "version": "4.0.0", @@ -12133,13 +11326,13 @@ } }, "node_modules/pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -12165,9 +11358,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -12186,17 +11379,17 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -12222,9 +11415,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "4.0.2", @@ -12265,10 +11458,25 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/point-in-polygon-hao": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz", + "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" + }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "funding": [ { "type": "opencollective", @@ -12285,8 +11493,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -12449,9 +11657,9 @@ } }, "node_modules/postgres-range": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.3.tgz", - "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -12489,20 +11697,16 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } @@ -12639,11 +11843,11 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -13695,9 +12899,9 @@ } }, "node_modules/rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -13710,22 +12914,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, @@ -13880,9 +13084,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -13915,10 +13119,13 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } }, "node_modules/serialize-javascript": { "version": "6.0.2", @@ -13930,14 +13137,14 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -14066,13 +13273,17 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14152,26 +13363,6 @@ "ws": "~8.17.1" } }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -14194,9 +13385,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -14267,9 +13458,9 @@ } }, "node_modules/sql-formatter": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.1.tgz", - "integrity": "sha512-lw/G/emIJ+tVspOtOFzfD2YFFMN3MFPxGnbWl1DlJLB+fsX7X7zMqSRM1SLSn2YuaRJ0lTe7AMknHDqmIW1Y8w==", + "version": "15.4.2", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.2.tgz", + "integrity": "sha512-Pw4aAgfuyml/SHMlhbJhyOv+GR+Z1HNb9sgX3CVBVdN5YNM+v2VWkYJ3NNbYS7cu37GY3vP/PgnwoVynCuXRxg==", "dev": true, "dependencies": { "argparse": "^2.0.1", @@ -14417,18 +13608,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -14670,9 +13849,9 @@ } }, "node_modules/tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14859,9 +14038,9 @@ } }, "node_modules/testcontainers": { - "version": "10.13.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.0.tgz", - "integrity": "sha512-SDblQvirbJw1ZpenxaAairGtAesw5XMOCHLbRhTTUBJtBkZJGce8Vx/I8lXQxWIM8HRXsg3HILTHGQvYo4x7wQ==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.2.tgz", + "integrity": "sha512-LfEll+AG/1Ks3n4+IA5lpyBHLiYh/hSfI4+ERa6urwfQscbDU+M2iW1qPQrHQi+xJXQRYy4whyK1IEHdmxWa3Q==", "dev": true, "dependencies": { "@balena/dockerignore": "^1.0.2", @@ -14940,15 +14119,21 @@ "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==" }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", "dev": true }, "node_modules/tinypool": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.0.tgz", - "integrity": "sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", "dev": true, "engines": { "node": "^18.0.0 || >=20.0.0" @@ -14964,9 +14149,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, "engines": { "node": ">=14.0.0" @@ -15388,9 +14573,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "devOptional": true, "bin": { "tsc": "bin/tsc", @@ -15401,9 +14586,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==", + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", "funding": [ { "type": "opencollective", @@ -15418,6 +14603,9 @@ "url": "https://github.com/sponsors/faisalman" } ], + "bin": { + "ua-parser-js": "script/cli.js" + }, "engines": { "node": "*" } @@ -15638,14 +14826,14 @@ } }, "node_modules/vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -15664,6 +14852,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -15681,6 +14870,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -15693,15 +14885,14 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.2.tgz", + "integrity": "sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==", "dev": true, "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -15734,29 +14925,29 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.2", + "@vitest/mocker": "2.1.2", + "@vitest/pretty-format": "^2.1.2", + "@vitest/runner": "2.1.2", + "@vitest/snapshot": "2.1.2", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -15771,8 +14962,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, @@ -15833,12 +15024,11 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.93.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", - "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -15847,7 +15037,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -16013,15 +15203,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -16625,163 +15815,163 @@ } }, "@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "dev": true, "optional": true }, "@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "dev": true, "optional": true }, @@ -16811,6 +16001,12 @@ "minimatch": "^3.1.2" } }, + "@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true + }, "@eslint/eslintrc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", @@ -16855,9 +16051,9 @@ } }, "@eslint/js": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", - "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", "dev": true }, "@eslint/object-schema": { @@ -16866,6 +16062,15 @@ "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", "dev": true }, + "@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "requires": { + "levn": "^0.4.1" + } + }, "@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -17302,9 +16507,9 @@ } }, "@nestjs/cli": { - "version": "10.4.4", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.4.tgz", - "integrity": "sha512-WKERbSZJGof0+9XeeMmWnb/9FpNxogcB5eTJTHjc9no0ymdTw3jTzT+KZL9iC/hGqBpuomDLaNFCYbAOt29nBw==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", + "integrity": "sha512-FP7Rh13u8aJbHe+zZ7hM0CC4785g9Pw4lz4r2TTgRtf0zTxSWMkJaPEwyjX8SK9oWK2GsYxl+fKpwVZNbmnj9A==", "dev": true, "requires": { "@angular-devkit/core": "17.3.8", @@ -17324,7 +16529,7 @@ "tsconfig-paths": "4.2.0", "tsconfig-paths-webpack-plugin": "4.1.0", "typescript": "5.3.3", - "webpack": "5.93.0", + "webpack": "5.94.0", "webpack-node-externals": "3.0.0" }, "dependencies": { @@ -17337,13 +16542,20 @@ } }, "@nestjs/common": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.1.tgz", - "integrity": "sha512-4CkrDx0s4XuWqFjX8WvOFV7Y6RGJd0P2OBblkhZS7nwoctoSuW5pyEa8SWak6YHNGrHRpFb6ymm5Ai4LncwRVA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.4.tgz", + "integrity": "sha512-0j2/zqRw9nvHV1GKTktER8B/hIC/Z8CYFjN/ZqUuvwayCH+jZZBhCR2oRyuvLTXdnlSmtCAg2xvQ0ULqQvzqhA==", "requires": { "iterare": "1.2.1", - "tslib": "2.6.3", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/config": { @@ -17357,16 +16569,23 @@ } }, "@nestjs/core": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.1.tgz", - "integrity": "sha512-9I1WdfOBCCHdUm+ClBJupOuZQS6UxzIWHIq6Vp1brAA5ZKl/Wq6BVwSsbnUJGBy3J3PM2XHmR0EQ4fwX3nR7lA==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", + "integrity": "sha512-y9tjmAzU6LTh1cC/lWrRsCcOd80khSR0qAHAqwY2svbW+AhsR/XCzgpZrAAKJrm/dDfjLCZKyxJSayeirGcW5Q==", "requires": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "3.2.0", - "tslib": "2.6.3", + "path-to-regexp": "3.3.0", + "tslib": "2.7.0", "uid": "2.0.2" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/event-emitter": { @@ -17384,30 +16603,44 @@ "requires": {} }, "@nestjs/platform-express": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.1.tgz", - "integrity": "sha512-ccfqIDAq/bg1ShLI5KGtaLaYGykuAdvCi57ohewH7eKJSIpWY1DQjbgKlFfXokALYUq1YOMGqjeZ244OWHfDQg==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.4.tgz", + "integrity": "sha512-y52q1MxhbHaT3vAgWd08RgiYon0lJgtTa8U6g6gV0KI0IygwZhDQFJVxnrRDUdxQGIP5CKHmfQu3sk9gTNFoEA==", "requires": { - "body-parser": "1.20.2", + "body-parser": "1.20.3", "cors": "2.8.5", - "express": "4.19.2", + "express": "4.21.0", "multer": "1.4.4-lts.1", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/platform-socket.io": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.1.tgz", - "integrity": "sha512-cxn5vKBAbqtEVPl0qVcJpR4sC12+hzcY/mYXGW6ippOKQDBNc2OF8oZXu6V3O1MvAl+VM7eNNEsLmP9DRKQlnw==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.4.tgz", + "integrity": "sha512-5GEYUA3sNbX2jOBP6FmrIK/zv9VCdvpdr4Sef1OKvt1U0qsV1YgmWPWDPumZM77n5DI0VHSJPyo7yjZaEKWOiQ==", "requires": { "socket.io": "4.7.5", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@nestjs/schedule": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", - "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", "requires": { "cron": "3.1.7", "uuid": "10.0.0" @@ -17442,25 +16675,33 @@ } }, "@nestjs/swagger": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.0.tgz", - "integrity": "sha512-dCiwKkRxcR7dZs5jtrGspBAe/nqJd1AYzOBTzw9iCdbq3BGrLpwokelk6lFZPe4twpTsPQqzNKBwKzVbI6AR/g==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", "requires": { "@microsoft/tsdoc": "^0.15.0", "@nestjs/mapped-types": "2.0.5", "js-yaml": "4.1.0", "lodash": "4.17.21", - "path-to-regexp": "3.2.0", + "path-to-regexp": "3.3.0", "swagger-ui-dist": "5.17.14" } }, "@nestjs/testing": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.1.tgz", - "integrity": "sha512-pR+su5+YGqCLH0RhhVkPowQK7FCORU0/PWAywPK7LScAOtD67ZoviZ7hAU4vnGdwkg4HCB0D7W8Bkg19CGU8Xw==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.4.tgz", + "integrity": "sha512-qRGFj51A5RM7JqA8pcyEwSLA3Y0dle/PAZ8oxP0suimoCusRY3Tk7wYqutZdCNj1ATb678SDaUZDHk2pwSv9/g==", "dev": true, "requires": { - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "dev": true + } } }, "@nestjs/typeorm": { @@ -17472,13 +16713,20 @@ } }, "@nestjs/websockets": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.1.tgz", - "integrity": "sha512-p0Eq94WneczV2bnLEu9hl24iCIfH5eUCGgBuYOkVDySBwvya5L+gD4wUoqIqGoX1c6rkhQa+pMR7pi1EY4t93w==", + "version": "10.4.4", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.4.tgz", + "integrity": "sha512-ZHnak04i/iKBS0csjJa7K6D6xdsB0Yz6duJuCR7xGLItchFK+Ne21m9rEF8ffvW74U7UAYkQHBgD5242LBBYiQ==", "requires": { "iterare": "1.2.1", "object-hash": "3.0.0", - "tslib": "2.6.3" + "tslib": "2.7.0" + }, + "dependencies": { + "tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==" + } } }, "@next/env": { @@ -17584,132 +16832,65 @@ "integrity": "sha512-I/s6F7yKUDdtMsoBWXJe8Qz40Tui5vsuKCWJEWVL+5q9sSWRzzx6v2KeNsOBEwd94j0eWkpWCH4yB6rZg9Mf0w==" }, "@opentelemetry/api-logs": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.0.tgz", - "integrity": "sha512-HxjD7xH9iAE4OyhNaaSec65i1H6QZYBWSwWkowFfsc5YAcDvJG30/J1sRKXEQqdmUcKTXEAnA66UciqZha/4+Q==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", + "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", "requires": { "@opentelemetry/api": "^1.0.0" } }, "@opentelemetry/auto-instrumentations-node": { - "version": "0.49.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.49.2.tgz", - "integrity": "sha512-xtETEPmAby/3MMmedv8Z/873sdLTWg+Vq98rtm4wbwvAiXBB/ao8qRyzRlvR2MR6puEr+vIB/CXeyJnzNA3cyw==", - "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/instrumentation-amqplib": "^0.41.0", - "@opentelemetry/instrumentation-aws-lambda": "^0.43.0", - "@opentelemetry/instrumentation-aws-sdk": "^0.43.1", - "@opentelemetry/instrumentation-bunyan": "^0.40.0", - "@opentelemetry/instrumentation-cassandra-driver": "^0.40.0", - "@opentelemetry/instrumentation-connect": "^0.38.0", - "@opentelemetry/instrumentation-cucumber": "^0.8.0", - "@opentelemetry/instrumentation-dataloader": "^0.11.0", - "@opentelemetry/instrumentation-dns": "^0.38.0", - "@opentelemetry/instrumentation-express": "^0.41.1", - "@opentelemetry/instrumentation-fastify": "^0.38.0", - "@opentelemetry/instrumentation-fs": "^0.14.0", - "@opentelemetry/instrumentation-generic-pool": "^0.38.1", - "@opentelemetry/instrumentation-graphql": "^0.42.0", - "@opentelemetry/instrumentation-grpc": "^0.52.0", - "@opentelemetry/instrumentation-hapi": "^0.40.0", - "@opentelemetry/instrumentation-http": "^0.52.0", - "@opentelemetry/instrumentation-ioredis": "^0.42.0", - "@opentelemetry/instrumentation-kafkajs": "^0.2.0", - "@opentelemetry/instrumentation-knex": "^0.39.0", - "@opentelemetry/instrumentation-koa": "^0.42.0", - "@opentelemetry/instrumentation-lru-memoizer": "^0.39.0", - "@opentelemetry/instrumentation-memcached": "^0.38.0", - "@opentelemetry/instrumentation-mongodb": "^0.46.0", - "@opentelemetry/instrumentation-mongoose": "^0.41.0", - "@opentelemetry/instrumentation-mysql": "^0.40.0", - "@opentelemetry/instrumentation-mysql2": "^0.40.0", - "@opentelemetry/instrumentation-nestjs-core": "^0.39.0", - "@opentelemetry/instrumentation-net": "^0.38.0", - "@opentelemetry/instrumentation-pg": "^0.43.0", - "@opentelemetry/instrumentation-pino": "^0.41.0", - "@opentelemetry/instrumentation-redis": "^0.41.0", - "@opentelemetry/instrumentation-redis-4": "^0.41.1", - "@opentelemetry/instrumentation-restify": "^0.40.0", - "@opentelemetry/instrumentation-router": "^0.39.0", - "@opentelemetry/instrumentation-socket.io": "^0.41.0", - "@opentelemetry/instrumentation-tedious": "^0.13.0", - "@opentelemetry/instrumentation-undici": "^0.5.0", - "@opentelemetry/instrumentation-winston": "^0.39.0", - "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.0", - "@opentelemetry/resource-detector-aws": "^1.6.0", - "@opentelemetry/resource-detector-azure": "^0.2.10", - "@opentelemetry/resource-detector-container": "^0.4.0", - "@opentelemetry/resource-detector-gcp": "^0.29.10", + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-node/-/auto-instrumentations-node-0.50.0.tgz", + "integrity": "sha512-LqoSiWrOM4Cnr395frDHL4R/o5c2fuqqrqW8sZwhxvkasImmVlyL66YMPHllM2O5xVj2nP2ANUKHZSd293meZA==", + "requires": { + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/instrumentation-amqplib": "^0.42.0", + "@opentelemetry/instrumentation-aws-lambda": "^0.44.0", + "@opentelemetry/instrumentation-aws-sdk": "^0.44.0", + "@opentelemetry/instrumentation-bunyan": "^0.41.0", + "@opentelemetry/instrumentation-cassandra-driver": "^0.41.0", + "@opentelemetry/instrumentation-connect": "^0.39.0", + "@opentelemetry/instrumentation-cucumber": "^0.9.0", + "@opentelemetry/instrumentation-dataloader": "^0.12.0", + "@opentelemetry/instrumentation-dns": "^0.39.0", + "@opentelemetry/instrumentation-express": "^0.42.0", + "@opentelemetry/instrumentation-fastify": "^0.39.0", + "@opentelemetry/instrumentation-fs": "^0.15.0", + "@opentelemetry/instrumentation-generic-pool": "^0.39.0", + "@opentelemetry/instrumentation-graphql": "^0.43.0", + "@opentelemetry/instrumentation-grpc": "^0.53.0", + "@opentelemetry/instrumentation-hapi": "^0.41.0", + "@opentelemetry/instrumentation-http": "^0.53.0", + "@opentelemetry/instrumentation-ioredis": "^0.43.0", + "@opentelemetry/instrumentation-kafkajs": "^0.3.0", + "@opentelemetry/instrumentation-knex": "^0.40.0", + "@opentelemetry/instrumentation-koa": "^0.43.0", + "@opentelemetry/instrumentation-lru-memoizer": "^0.40.0", + "@opentelemetry/instrumentation-memcached": "^0.39.0", + "@opentelemetry/instrumentation-mongodb": "^0.47.0", + "@opentelemetry/instrumentation-mongoose": "^0.42.0", + "@opentelemetry/instrumentation-mysql": "^0.41.0", + "@opentelemetry/instrumentation-mysql2": "^0.41.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.40.0", + "@opentelemetry/instrumentation-net": "^0.39.0", + "@opentelemetry/instrumentation-pg": "^0.44.0", + "@opentelemetry/instrumentation-pino": "^0.42.0", + "@opentelemetry/instrumentation-redis": "^0.42.0", + "@opentelemetry/instrumentation-redis-4": "^0.42.0", + "@opentelemetry/instrumentation-restify": "^0.41.0", + "@opentelemetry/instrumentation-router": "^0.40.0", + "@opentelemetry/instrumentation-socket.io": "^0.42.0", + "@opentelemetry/instrumentation-tedious": "^0.14.0", + "@opentelemetry/instrumentation-undici": "^0.6.0", + "@opentelemetry/instrumentation-winston": "^0.40.0", + "@opentelemetry/resource-detector-alibaba-cloud": "^0.29.1", + "@opentelemetry/resource-detector-aws": "^1.6.1", + "@opentelemetry/resource-detector-azure": "^0.2.11", + "@opentelemetry/resource-detector-container": "^0.4.1", + "@opentelemetry/resource-detector-gcp": "^0.29.11", "@opentelemetry/resources": "^1.24.0", - "@opentelemetry/sdk-node": "^0.52.0" - }, - "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/instrumentation": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.1.tgz", - "integrity": "sha512-uXJbYU/5/MBHjMp1FqrILLRuiJCs3Ofk0MeRDk8g1S1gD47U8X3JnSwcMO1rtRo1x1a7zKaQHaoYu49p/4eSKw==", - "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - } - }, - "@opentelemetry/sdk-node": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.52.1.tgz", - "integrity": "sha512-uEG+gtEr6eKd8CVWeKMhH2olcCHM9dEK68pe0qE0be32BcCRsvYURhHaD1Srngh1SQcnQzZ4TP324euxqtBOJA==", - "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/exporter-trace-otlp-grpc": "0.52.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", - "@opentelemetry/exporter-trace-otlp-proto": "0.52.1", - "@opentelemetry/exporter-zipkin": "1.25.1", - "@opentelemetry/instrumentation": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/sdk-trace-node": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - }, - "import-in-the-middle": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", - "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", - "requires": { - "acorn": "^8.8.2", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - } + "@opentelemetry/sdk-node": "^0.53.0" } }, "@opentelemetry/context-async-hooks": { @@ -17719,11 +16900,11 @@ "requires": {} }, "@opentelemetry/core": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.0.tgz", - "integrity": "sha512-n0B3s8rrqGrasTgNkXLKXzN0fXo+6IYP7M5b7AMsrZM33f/y6DS6kJ0Btd7SespASWq8bgL3taLo0oe0vB52IQ==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", + "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", "requires": { - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/exporter-logs-otlp-grpc": { @@ -17738,22 +16919,6 @@ "@opentelemetry/sdk-logs": "0.53.0" }, "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.27.0" - } - }, "@opentelemetry/otlp-exporter-base": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", @@ -17825,11 +16990,6 @@ "@opentelemetry/resources": "1.26.0", "@opentelemetry/semantic-conventions": "1.27.0" } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" } } }, @@ -17845,22 +17005,6 @@ "@opentelemetry/sdk-logs": "0.53.0" }, "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.27.0" - } - }, "@opentelemetry/otlp-exporter-base": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", @@ -17921,11 +17065,6 @@ "@opentelemetry/resources": "1.26.0", "@opentelemetry/semantic-conventions": "1.27.0" } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" } } }, @@ -17943,22 +17082,6 @@ "@opentelemetry/sdk-trace-base": "1.26.0" }, "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.27.0" - } - }, "@opentelemetry/otlp-exporter-base": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.53.0.tgz", @@ -18019,11 +17142,6 @@ "@opentelemetry/resources": "1.26.0", "@opentelemetry/semantic-conventions": "1.27.0" } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" } } }, @@ -18037,14 +17155,6 @@ "@opentelemetry/sdk-metrics": "1.26.0" }, "dependencies": { - "@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.27.0" - } - }, "@opentelemetry/resources": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.26.0.tgz", @@ -18060,121 +17170,8 @@ "integrity": "sha512-0SvDXmou/JjzSDOjUmetAAvcKQW6ZrvosU0rkbDGpXvvZN+pQF6JbK/Kd4hNdK4q/22yeruqvukXEJyySTzyTQ==", "requires": { "@opentelemetry/core": "1.26.0", - "@opentelemetry/resources": "1.26.0" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" - } - } - }, - "@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.52.1.tgz", - "integrity": "sha512-pVkSH20crBwMTqB3nIN4jpQKUEoB0Z94drIHpYyEqs7UBr+I0cpYyOR3bqjA/UasQUMROb3GX8ZX4/9cVRqGBQ==", - "requires": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-grpc-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, - "@opentelemetry/exporter-trace-otlp-http": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.52.1.tgz", - "integrity": "sha512-05HcNizx0BxcFKKnS5rwOV+2GevLTVIRA0tRgWYyw4yCgR53Ic/xk83toYKts7kbzcI+dswInUg/4s8oyA+tqg==", - "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, - "@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.52.1.tgz", - "integrity": "sha512-pt6uX0noTQReHXNeEslQv7x311/F1gJzMnp1HD2qgypLRPbXDeMzzeTngRTUaUbP6hqWNtPxuLr4DEoZG+TcEQ==", - "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, - "@opentelemetry/exporter-zipkin": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.25.1.tgz", - "integrity": "sha512-RmOwSvkimg7ETwJbUOPTMhJm9A9bG1U8s7Zo3ajDh4zM7eYcycQ0dM7FbLD6NXWbI2yj7UY4q8BKinKYBQksyw==", - "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" + "@opentelemetry/resources": "1.26.0" } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" } } }, @@ -18188,306 +17185,306 @@ } }, "@opentelemetry/instrumentation": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.52.0.tgz", - "integrity": "sha512-LPwSIrw+60cheWaXsfGL8stBap/AppKQJFE+qqRvzYrgttXFH2ofoIMxWadeqPTq4BYOXM/C7Bdh/T+B60xnlQ==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz", + "integrity": "sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==", "requires": { - "@opentelemetry/api-logs": "0.52.0", - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.8.0", + "@opentelemetry/api-logs": "0.53.0", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", "require-in-the-middle": "^7.1.1", "semver": "^7.5.2", "shimmer": "^1.2.1" } }, "@opentelemetry/instrumentation-amqplib": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.41.0.tgz", - "integrity": "sha512-00Oi6N20BxJVcqETjgNzCmVKN+I5bJH/61IlHiIWd00snj1FdgiIKlpE4hYVacTB2sjIBB3nTbHskttdZEE2eg==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.42.0.tgz", + "integrity": "sha512-fiuU6OKsqHJiydHWgTRQ7MnIrJ2lEqsdgFtNIH4LbAUJl/5XmrIeoDzDnox+hfkgWK65jsleFuQDtYb5hW1koQ==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-aws-lambda": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.43.0.tgz", - "integrity": "sha512-pSxcWlsE/pCWQRIw92sV2C+LmKXelYkjkA7C5s39iPUi4pZ2lA1nIiw+1R/y2pDEhUHcaKkNyljQr3cx9ZpVlQ==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.44.0.tgz", + "integrity": "sha512-6vmr7FJIuopZzsQjEQTp4xWtF6kBp7DhD7pPIN8FN0dKUKyuVraABIpgWjMfelaUPy4rTYUGkYqPluhG0wx8Dw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/propagator-aws-xray": "^1.3.1", "@opentelemetry/resources": "^1.8.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/aws-lambda": "8.10.122" + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/aws-lambda": "8.10.143" } }, "@opentelemetry/instrumentation-aws-sdk": { - "version": "0.43.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.43.1.tgz", - "integrity": "sha512-qLT2cCniJ5W+6PFzKbksnoIQuq9pS83nmgaExfUwXVvlwi0ILc50dea0tWBHZMkdIDa/zZdcuFrJ7+fUcSnRow==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.44.0.tgz", + "integrity": "sha512-HIWFg4TDQsayceiikOnruMmyQ0SZYW6WiR+wknWwWVLHC3lHTCpAnqzp5V42ckArOdlwHZu2Jvq2GMSM4Myx3w==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/propagation-utils": "^0.30.10", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/propagation-utils": "^0.30.11", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-bunyan": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.40.0.tgz", - "integrity": "sha512-aZ4cXaGWwj79ZXSYrgFVsrDlE4mmf2wfvP9bViwRc0j75A6eN6GaHYHqufFGMTCqASQn5pIjjP+Bx+PWTGiofw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-bunyan/-/instrumentation-bunyan-0.41.0.tgz", + "integrity": "sha512-NoQS+gcwQ7pzb2PZFyra6bAxDAVXBMmpKxBblEuXJWirGrAksQllg9XTdmqhrwT/KxUYrbVca/lMams7e51ysg==", "requires": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/api-logs": "^0.53.0", + "@opentelemetry/instrumentation": "^0.53.0", "@types/bunyan": "1.8.9" } }, "@opentelemetry/instrumentation-cassandra-driver": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.40.0.tgz", - "integrity": "sha512-JxbM39JU7HxE9MTKKwi6y5Z3mokjZB2BjwfqYi4B3Y29YO3I42Z7eopG6qq06yWZc+nQli386UDQe0d9xKmw0A==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cassandra-driver/-/instrumentation-cassandra-driver-0.41.0.tgz", + "integrity": "sha512-hvTNcC8qjCQEHZTLAlTmDptjsEGqCKpN+90hHH8Nn/GwilGr5TMSwGrlfstdJuZWyw8HAnRUed6bcjvmHHk2Xw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-connect": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.38.0.tgz", - "integrity": "sha512-2/nRnx3pjYEmdPIaBwtgtSviTKHWnDZN3R+TkRUnhIVrvBKVcq+I5B2rtd6mr6Fe9cHlZ9Ojcuh7pkNh/xdWWg==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.39.0.tgz", + "integrity": "sha512-pGBiKevLq7NNglMgqzmeKczF4XQMTOUOTkK8afRHMZMnrK3fcETyTH7lVaSozwiOM3Ws+SuEmXZT7DYrrhxGlg==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/connect": "3.4.36" } }, "@opentelemetry/instrumentation-cucumber": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.8.0.tgz", - "integrity": "sha512-ieTm4RBIlZt2brPwtX5aEZYtYnkyqhAVXJI9RIohiBVMe5DxiwCwt+2Exep/nDVqGPX8zRBZUl4AEw423OxJig==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-cucumber/-/instrumentation-cucumber-0.9.0.tgz", + "integrity": "sha512-4PQNFnIqnA2WM3ZHpr0xhZpHSqJ5xJ6ppTIzZC7wPqe+ZBpj41vG8B6ieqiPfq+im4QdqbYnzLb3rj48GDEN9g==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-dataloader": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.11.0.tgz", - "integrity": "sha512-27urJmwkH4KDaMJtEv1uy2S7Apk4XbN4AgWMdfMJbi7DnOduJmeuA+DpJCwXB72tEWXo89z5T3hUVJIDiSNmNw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.12.0.tgz", + "integrity": "sha512-pnPxatoFE0OXIZDQhL2okF//dmbiWFzcSc8pUg9TqofCLYZySSxDCgQc69CJBo5JnI3Gz1KP+mOjS4WAeRIH4g==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/instrumentation-dns": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.38.0.tgz", - "integrity": "sha512-Um07I0TQXDWa+ZbEAKDFUxFH40dLtejtExDOMLNJ1CL8VmOmA71qx93Qi/QG4tGkiI1XWqr7gF/oiMCJ4m8buQ==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dns/-/instrumentation-dns-0.39.0.tgz", + "integrity": "sha512-+iPzvXqVdJa67QBuz2tuP0UI3LS1/cMMo6dS7360DDtOQX+sQzkiN+mo3Omn4T6ZRhkTDw6c7uwsHBcmL31+1g==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "semver": "^7.5.4" } }, "@opentelemetry/instrumentation-express": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.41.1.tgz", - "integrity": "sha512-uRx0V3LPGzjn2bxAnV8eUsDT82vT7NTwI0ezEuPMBOTOsnPpGhWdhcdNdhH80sM4TrWrOfXm9HGEdfWE3TRIww==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.42.0.tgz", + "integrity": "sha512-YNcy7ZfGnLsVEqGXQPT+S0G1AE46N21ORY7i7yUQyfhGAL4RBjnZUqefMI0NwqIl6nGbr1IpF0rZGoN8Q7x12Q==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-fastify": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.38.0.tgz", - "integrity": "sha512-HBVLpTSYpkQZ87/Df3N0gAw7VzYZV3n28THIBrJWfuqw3Or7UqdhnjeuMIPQ04BKk3aZc0cWn2naSQObbh5vXw==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.39.0.tgz", + "integrity": "sha512-SS9uSlKcsWZabhBp2szErkeuuBDgxOUlllwkS92dVaWRnMmwysPhcEgHKB8rUe3BHg/GnZC1eo1hbTZv4YhfoA==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-fs": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.14.0.tgz", - "integrity": "sha512-pVc8P5AgliC1DphyyBUgsxXlm2XaPH4BpYvt7rAZDMIqUpRk8gs19SioABtKqqxvFzg5jPtgJfJsdxq0Y+maLw==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.15.0.tgz", + "integrity": "sha512-JWVKdNLpu1skqZQA//jKOcKdJC66TWKqa2FUFq70rKohvaSq47pmXlnabNO+B/BvLfmidfiaN35XakT5RyMl2Q==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/instrumentation-generic-pool": { - "version": "0.38.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.38.1.tgz", - "integrity": "sha512-WvssuKCuavu/hlq661u82UWkc248cyI/sT+c2dEIj6yCk0BUkErY1D+9XOO+PmHdJNE+76i2NdcvQX5rJrOe/w==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.39.0.tgz", + "integrity": "sha512-y4v8Y+tSfRB3NNBvHjbjrn7rX/7sdARG7FuK6zR8PGb28CTa0kHpEGCJqvL9L8xkTNvTXo+lM36ajFGUaK1aNw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/instrumentation-graphql": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.42.0.tgz", - "integrity": "sha512-N8SOwoKL9KQSX7z3gOaw5UaTeVQcfDO1c21csVHnmnmGUoqsXbArK2B8VuwPWcv6/BC/i3io+xTo7QGRZ/z28Q==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.43.0.tgz", + "integrity": "sha512-aI3YMmC2McGd8KW5du1a2gBA0iOMOGLqg4s9YjzwbjFwjlmMNFSK1P3AIg374GWg823RPUGfVTIgZ/juk9CVOA==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/instrumentation-grpc": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.52.0.tgz", - "integrity": "sha512-YYhA2pbhMWgF5Hp6eR7AHp1utzZQ3Y0VB8GIwd8zJoLtAuQRZa1N29DUtZ+t/pGRJF+xGPVI+vP+7ugHgeN0zQ==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.53.0.tgz", + "integrity": "sha512-Ss338T92yE1UCgr9zXSY3cPuaAy27uQw+wAC5IwsQKCXL5wwkiOgkd+2Ngksa9EGsgUEMwGeHi76bDdHFJ5Rrw==", "requires": { - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0" + "@opentelemetry/instrumentation": "0.53.0", + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/instrumentation-hapi": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.40.0.tgz", - "integrity": "sha512-8U/w7Ifumtd2bSN1OLaSwAAFhb9FyqWUki3lMMB0ds+1+HdSxYBe9aspEJEgvxAqOkrQnVniAPTEGf1pGM7SOw==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.41.0.tgz", + "integrity": "sha512-jKDrxPNXDByPlYcMdZjNPYCvw0SQJjN+B1A+QH+sx+sAHsKSAf9hwFiJSrI6C4XdOls43V/f/fkp9ITkHhKFbQ==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-http": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.52.0.tgz", - "integrity": "sha512-E6ywZuxTa4LnVXZGwL1oj3e2Eog1yIaNqa8KjKXoGkDNKte9/SjQnePXOmhQYI0A9nf0UyFbP9aKd+yHrkJXUA==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.53.0.tgz", + "integrity": "sha512-H74ErMeDuZfj7KgYCTOFGWF5W9AfaPnqLQQxeFq85+D29wwV2yqHbz2IKLYpkOh7EI6QwDEl7rZCIxjJLyc/CQ==", "requires": { - "@opentelemetry/core": "1.25.0", - "@opentelemetry/instrumentation": "0.52.0", - "@opentelemetry/semantic-conventions": "1.25.0", + "@opentelemetry/core": "1.26.0", + "@opentelemetry/instrumentation": "0.53.0", + "@opentelemetry/semantic-conventions": "1.27.0", "semver": "^7.5.2" } }, "@opentelemetry/instrumentation-ioredis": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.42.0.tgz", - "integrity": "sha512-P11H168EKvBB9TUSasNDOGJCSkpT44XgoM6d3gRIWAa9ghLpYhl0uRkS8//MqPzcJVHr3h3RmfXIpiYLjyIZTw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.43.0.tgz", + "integrity": "sha512-i3Dke/LdhZbiUAEImmRG3i7Dimm/BD7t8pDDzwepSvIQ6s2X6FPia7561gw+64w+nx0+G9X14D7rEfaMEmmjig==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-kafkajs": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.2.0.tgz", - "integrity": "sha512-uKKmhEFd0zR280tJovuiBG7cfnNZT4kvVTvqtHPxQP7nOmRbJstCYHFH13YzjVcKjkmoArmxiSulmZmF7SLIlg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.3.0.tgz", + "integrity": "sha512-UnkZueYK1ise8FXQeKlpBd7YYUtC7mM8J0wzUSccEfc/G8UqHQqAzIyYCUOUPUKp8GsjLnWOOK/3hJc4owb7Jg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.24.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-knex": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.39.0.tgz", - "integrity": "sha512-lRwTqIKQecPWDkH1KEcAUcFhCaNssbKSpxf4sxRTAROCwrCEnYkjOuqJHV+q1/CApjMTaKu0Er4LBv/6bDpoxA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.40.0.tgz", + "integrity": "sha512-6jka2jfX8+fqjEbCn6hKWHVWe++mHrIkLQtaJqUkBt3ZBs2xn1+y0khxiDS0v/mNb0bIKDJWwtpKFfsQDM1Geg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-koa": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.42.0.tgz", - "integrity": "sha512-H1BEmnMhho8o8HuNRq5zEI4+SIHDIglNB7BPKohZyWG4fWNuR7yM4GTlR01Syq21vODAS7z5omblScJD/eZdKw==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.43.0.tgz", + "integrity": "sha512-lDAhSnmoTIN6ELKmLJBplXzT/Jqs5jGZehuG22EdSMaTwgjMpxMDI1YtlKEhiWPWkrz5LUsd0aOO0ZRc9vn3AQ==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.39.0.tgz", - "integrity": "sha512-eU1Wx1RRTR/2wYXFzH9gcpB8EPmhYlNDIUHzUXjyUE0CAXEJhBLkYNlzdaVCoQDw2neDqS+Woshiia6+emWK9A==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.40.0.tgz", + "integrity": "sha512-21xRwZsEdMPnROu/QsaOIODmzw59IYpGFmuC4aFWvMj6stA8+Ei1tX67nkarJttlNjoM94um0N4X26AD7ff54A==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/instrumentation-memcached": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.38.0.tgz", - "integrity": "sha512-tPmyqQEZNyrvg6G+iItdlguQEcGzfE+bJkpQifmBXmWBnoS5oU3UxqtyYuXGL2zI9qQM5yMBHH4nRXWALzy7WA==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-memcached/-/instrumentation-memcached-0.39.0.tgz", + "integrity": "sha512-WfwvKAZ9I1qILRP5EUd88HQjwAAL+trXpCpozjBi4U6a0A07gB3fZ5PFAxbXemSjF5tHk9KVoROnqHvQ+zzFSQ==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/memcached": "^2.2.6" } }, "@opentelemetry/instrumentation-mongodb": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.46.0.tgz", - "integrity": "sha512-VF/MicZ5UOBiXrqBslzwxhN7TVqzu1/LN/QDpkskqM0Zm0aZ4CVRbUygL8d7lrjLn15x5kGIe8VsSphMfPJzlA==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.47.0.tgz", + "integrity": "sha512-yqyXRx2SulEURjgOQyJzhCECSh5i1uM49NUaq9TqLd6fA7g26OahyJfsr9NE38HFqGRHpi4loyrnfYGdrsoVjQ==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/sdk-metrics": "^1.9.1", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-mongoose": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.41.0.tgz", - "integrity": "sha512-ivJg4QnnabFxxoI7K8D+in7hfikjte38sYzJB9v1641xJk9Esa7jM3hmbPB7lxwcgWJLVEDvfPwobt1if0tXxA==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.42.0.tgz", + "integrity": "sha512-AnWv+RaR86uG3qNEMwt3plKX1ueRM7AspfszJYVkvkehiicC3bHQA6vWdb6Zvy5HAE14RyFbu9+2hUUjR2NSyg==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-mysql": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.40.0.tgz", - "integrity": "sha512-d7ja8yizsOCNMYIJt5PH/fKZXjb/mS48zLROO4BzZTtDfhNCl2UM/9VIomP2qkGIFVouSJrGr/T00EzY7bPtKA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.41.0.tgz", + "integrity": "sha512-jnvrV6BsQWyHS2qb2fkfbfSb1R/lmYwqEZITwufuRl37apTopswu9izc0b1CYRp/34tUG/4k/V39PND6eyiNvw==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", - "@types/mysql": "2.15.22" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/mysql": "2.15.26" } }, "@opentelemetry/instrumentation-mysql2": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.40.0.tgz", - "integrity": "sha512-0xfS1xcqUmY7WE1uWjlmI67Xg3QsSUlNT+AcXHeA4BDUPwZtWqF4ezIwLgpVZfHOnkAEheqGfNSWd1PIu3Wnfg==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.41.0.tgz", + "integrity": "sha512-REQB0x+IzVTpoNgVmy5b+UnH1/mDByrneimP6sbDHkp1j8QOl1HyWOrBH/6YWR0nrbU3l825Em5PlybjT3232g==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1" } }, "@opentelemetry/instrumentation-nestjs-core": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.39.0.tgz", - "integrity": "sha512-mewVhEXdikyvIZoMIUry8eb8l3HUjuQjSjVbmLVTt4NQi35tkpnHQrG9bTRBrl3403LoWZ2njMPJyg4l6HfKvA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.40.0.tgz", + "integrity": "sha512-WF1hCUed07vKmf5BzEkL0wSPinqJgH7kGzOjjMAiTGacofNXjb/y4KQ8loj2sNsh5C/NN7s1zxQuCgbWbVTGKg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-net": { - "version": "0.38.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.38.0.tgz", - "integrity": "sha512-stjow1PijcmUquSmRD/fSihm/H61DbjPlJuJhWUe7P22LFPjFhsrSeiB5vGj3vn+QGceNAs+kioUTzMGPbNxtg==", + "version": "0.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-net/-/instrumentation-net-0.39.0.tgz", + "integrity": "sha512-rixHoODfI/Cx1B0mH1BpxCT0bRSxktuBDrt9IvpT2KSEutK5hR0RsRdgdz/GKk+BQ4u+IG6godgMSGwNQCueEA==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.23.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-pg": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.43.0.tgz", - "integrity": "sha512-og23KLyoxdnAeFs1UWqzSonuCkePUzCX30keSYigIzJe/6WSYA8rnEI5lobcxPEzg+GcU06J7jzokuEHbjVJNw==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.44.0.tgz", + "integrity": "sha512-oTWVyzKqXud1BYEGX1loo2o4k4vaU1elr3vPO8NZolrBtFvQ34nx4HgUaexUDuEog00qQt+MLR5gws/p+JXMLQ==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@opentelemetry/sql-common": "^0.40.1", "@types/pg": "8.6.1", - "@types/pg-pool": "2.0.4" + "@types/pg-pool": "2.0.6" }, "dependencies": { "@types/pg": { @@ -18503,182 +17500,95 @@ } }, "@opentelemetry/instrumentation-pino": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.41.0.tgz", - "integrity": "sha512-Kpv0fJRk/8iMzMk5Ue5BsUJfHkBJ2wQoIi/qduU1a1Wjx9GLj6J2G17PHjPK5mnZjPNzkFOXFADZMfgDioliQw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pino/-/instrumentation-pino-0.42.0.tgz", + "integrity": "sha512-SoX6FzucBfTuFNMZjdurJhcYWq2ve8/LkhmyVLUW31HpIB45RF1JNum0u4MkGisosDmXlK4njomcgUovShI+WA==", "requires": { - "@opentelemetry/api-logs": "^0.52.0", + "@opentelemetry/api-logs": "^0.53.0", "@opentelemetry/core": "^1.25.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/instrumentation-redis": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.41.0.tgz", - "integrity": "sha512-RJ1pwI3btykp67ts+5qZbaFSAAzacucwBet5/5EsKYtWBpHbWwV/qbGN/kIBzXg5WEZBhXLrR/RUq0EpEUpL3A==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.42.0.tgz", + "integrity": "sha512-jZBoqve0rEC51q0HuhjtZVq1DtUvJHzEJ3YKGvzGar2MU1J4Yt5+pQAQYh1W4jSoDyKeaI4hyeUdWM5N0c2lqA==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-redis-4": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.41.1.tgz", - "integrity": "sha512-UqJAbxraBk7s7pQTlFi5ND4sAUs4r/Ai7gsAVZTQDbHl2kSsOp7gpHcpIuN5dpcI2xnuhM2tkH4SmEhbrv2S6Q==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.42.0.tgz", + "integrity": "sha512-NaD+t2JNcOzX/Qa7kMy68JbmoVIV37fT/fJYzLKu2Wwd+0NCxt+K2OOsOakA8GVg8lSpFdbx4V/suzZZ2Pvdjg==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", + "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-restify": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.40.0.tgz", - "integrity": "sha512-sm/rH/GysY/KOEvZqYBZSLYFeXlBkHCgqPDgWc07tz+bHCN6mPs9P3otGOSTe7o3KAIM8Nc6ncCO59vL+jb2cA==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-restify/-/instrumentation-restify-0.41.0.tgz", + "integrity": "sha512-gKEo+X/wVKUBuD2WDDlF7SlDNBHMWjSQoLxFCsGqeKgHR0MGtwMel8uaDGg9LJ83nKqYy+7Vl/cDFxjba6H+/w==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-router": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.39.0.tgz", - "integrity": "sha512-LaXnVmD69WPC4hNeLzKexCCS19hRLrUw3xicneAMkzJSzNJvPyk7G6I7lz7VjQh1cooObPBt9gNyd3hhTCUrag==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-router/-/instrumentation-router-0.40.0.tgz", + "integrity": "sha512-bRo4RaclGFiKtmv/N1D0MuzO7DuxbeqMkMCbPPng6mDwzpHAMpHz/K/IxJmF+H1Hi/NYXVjCKvHGClageLe9eA==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-socket.io": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.41.0.tgz", - "integrity": "sha512-7fzDe9/FpO6NFizC/wnzXXX7bF9oRchsD//wFqy5g5hVEgXZCQ70IhxjrKdBvgjyIejR9T9zTvfQ6PfVKfkCAw==", + "version": "0.42.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-socket.io/-/instrumentation-socket.io-0.42.0.tgz", + "integrity": "sha512-xB5tdsBzuZyicQTO3hDzJIpHQ7V1BYJ6vWPWgl19gWZDBdjEGc3HOupjkd3BUJyDoDhbMEHGk2nNlkUU99EfkA==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/instrumentation-tedious": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.13.0.tgz", - "integrity": "sha512-Pob0+0R62AqXH50pjazTeGBy/1+SK4CYpFUBV5t7xpbpeuQezkkgVGvLca84QqjBqQizcXedjpUJLgHQDixPQg==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.14.0.tgz", + "integrity": "sha512-ofq7pPhSqvRDvD2FVx3RIWPj76wj4QubfrbqJtEx0A+fWoaYxJOCIQ92tYJh28elAmjMmgF/XaYuJuBhBv5J3A==", "requires": { - "@opentelemetry/instrumentation": "^0.52.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "@types/tedious": "^4.0.14" } }, "@opentelemetry/instrumentation-undici": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.5.0.tgz", - "integrity": "sha512-aNTeSrFAVcM9qco5DfZ9DNXu6hpMRe8Kt8nCDHfMWDB3pwgGVUE76jTdohc+H/7eLRqh4L7jqs5NSQoHw7S6ww==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.6.0.tgz", + "integrity": "sha512-ABJBhm5OdhGmbh0S/fOTE4N69IZ00CsHC5ijMYfzbw3E5NwLgpQk5xsljaECrJ8wz1SfXbO03FiSuu5AyRAkvQ==", "requires": { "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.52.0" + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/instrumentation-winston": { - "version": "0.39.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.39.0.tgz", - "integrity": "sha512-v/1xziLJ9CyB3YDjBSBzbB70Qd0JwWTo36EqWK5m3AR0CzsyMQQmf3ZIZM6sgx7hXMcRQ0pnEYhg6nhrUQPm9A==", - "requires": { - "@opentelemetry/api-logs": "^0.52.0", - "@opentelemetry/instrumentation": "^0.52.0" - } - }, - "@opentelemetry/otlp-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.52.1.tgz", - "integrity": "sha512-z175NXOtX5ihdlshtYBe5RpGeBoTXVCKPPLiQlD6FHvpM4Ch+p2B0yWKYSrBfLH24H9zjJiBdTrtD+hLlfnXEQ==", - "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-transformer": "0.52.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, - "@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.52.1.tgz", - "integrity": "sha512-zo/YrSDmKMjG+vPeA9aBBrsQM9Q/f2zo6N04WMB3yNldJRsgpRBeLLwvAt/Ba7dpehDLOEFBd1i2JCoaFtpCoQ==", - "requires": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/otlp-exporter-base": "0.52.1", - "@opentelemetry/otlp-transformer": "0.52.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, - "@opentelemetry/otlp-transformer": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.52.1.tgz", - "integrity": "sha512-I88uCZSZZtVa0XniRqQWKbjAUm73I8tpEy/uJYPPYw5d7BRdVk0RfTBQw8kSUl01oVWEuqxLDa802222MYyWHg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-winston/-/instrumentation-winston-0.40.0.tgz", + "integrity": "sha512-eMk2tKl86YJ8/yHvtDbyhrE35/R0InhO9zuHTflPx8T0+IvKVUhPV71MsJr32sImftqeOww92QHt4Jd+a5db4g==", "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/sdk-logs": "0.52.1", - "@opentelemetry/sdk-metrics": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "protobufjs": "^7.3.0" - }, - "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } + "@opentelemetry/api-logs": "^0.53.0", + "@opentelemetry/instrumentation": "^0.53.0" } }, "@opentelemetry/propagation-utils": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.10.tgz", - "integrity": "sha512-hhTW8pFp9PSyosYzzuUL9rdm7HF97w3OCyElufFHyUnYnKkCBbu8ne2LyF/KSdI/xZ81ubxWZs78hX4S7pLq5g==", + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagation-utils/-/propagation-utils-0.30.11.tgz", + "integrity": "sha512-rY4L/2LWNk5p/22zdunpqVmgz6uN419DsRTw5KFMa6u21tWhXS8devlMy4h8m8nnS20wM7r6yYweCNNKjgLYJw==", "requires": {} }, "@opentelemetry/propagator-aws-xray": { @@ -18689,64 +17599,18 @@ "@opentelemetry/core": "^1.0.0" } }, - "@opentelemetry/propagator-b3": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.25.1.tgz", - "integrity": "sha512-p6HFscpjrv7//kE+7L+3Vn00VEDUJB0n6ZrjkTYHrJ58QZ8B3ajSJhRbCcY6guQ3PDjTbxWklyvIN2ojVbIb1A==", - "requires": { - "@opentelemetry/core": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, - "@opentelemetry/propagator-jaeger": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.25.1.tgz", - "integrity": "sha512-nBprRf0+jlgxks78G/xq72PipVK+4or9Ypntw0gVZYNTCSK8rg5SeaGV19tV920CMqBD/9UIOiFr23Li/Q8tiA==", - "requires": { - "@opentelemetry/core": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, "@opentelemetry/redis-common": { "version": "0.36.2", "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==" }, "@opentelemetry/resource-detector-alibaba-cloud": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.0.tgz", - "integrity": "sha512-cYL1DfBwszTQcpzjiezzFkZp1bzevXjaVJ+VClrufHzH17S0RADcaLRQcLq4GqbWCGfvkJKUqBNz6f1SgfePgw==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-alibaba-cloud/-/resource-detector-alibaba-cloud-0.29.1.tgz", + "integrity": "sha512-Qshebw6azBuKUqGkVgambZlLS6Xh+LCoLXep1oqW1RSzSOHQxGYDsPs99v8NzO65eJHHOu8wc2yKsWZQAgYsSw==", "requires": { "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0" + "@opentelemetry/semantic-conventions": "^1.27.0" } }, "@opentelemetry/resource-detector-aws": { @@ -18757,13 +17621,6 @@ "@opentelemetry/core": "^1.0.0", "@opentelemetry/resources": "^1.10.0", "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "dependencies": { - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" - } } }, "@opentelemetry/resource-detector-azure": { @@ -18774,21 +17631,6 @@ "@opentelemetry/core": "^1.25.1", "@opentelemetry/resources": "^1.10.1", "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.27.0" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" - } } }, "@opentelemetry/resource-detector-container": { @@ -18798,23 +17640,16 @@ "requires": { "@opentelemetry/resources": "^1.10.0", "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "dependencies": { - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" - } } }, "@opentelemetry/resource-detector-gcp": { - "version": "0.29.10", - "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.10.tgz", - "integrity": "sha512-rm2HKJ9lsdoVvrbmkr9dkOzg3Uk0FksXNxvNBgrCprM1XhMoJwThI5i0h/5sJypISUAJlEeJS6gn6nROj/NpkQ==", + "version": "0.29.11", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.29.11.tgz", + "integrity": "sha512-07wJx4nyxD/c2z3n70OQOg8fmoO/baTsq8uU+f7tZaehRNQx76MPkRbV2L902N40Z21SPIG8biUZ30OXE9tOIg==", "requires": { "@opentelemetry/core": "^1.0.0", "@opentelemetry/resources": "^1.0.0", - "@opentelemetry/semantic-conventions": "^1.22.0", + "@opentelemetry/semantic-conventions": "^1.27.0", "gcp-metadata": "^6.0.0" } }, @@ -18842,39 +17677,6 @@ } } }, - "@opentelemetry/sdk-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.52.1.tgz", - "integrity": "sha512-MBYh+WcPPsN8YpRHRmK1Hsca9pVlyyKd4BxOC4SsgHACnl/bPp4Cri9hWhVm5+2tiQ9Zf4qSc1Jshw9tOLGWQA==", - "requires": { - "@opentelemetry/api-logs": "0.52.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1" - }, - "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.52.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz", - "integrity": "sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, "@opentelemetry/sdk-metrics": { "version": "1.25.1", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.25.1.tgz", @@ -18923,22 +17725,6 @@ "@opentelemetry/semantic-conventions": "1.27.0" }, "dependencies": { - "@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "requires": { - "@opentelemetry/api": "^1.0.0" - } - }, - "@opentelemetry/core": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.26.0.tgz", - "integrity": "sha512-1iKxXXE8415Cdv0yjG3G6hQnB5eVEsJce3QaawX8SjDn0mAS0ZM8fAbZZJD4ajvhC15cePvosSCut404KrIIvQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.27.0" - } - }, "@opentelemetry/exporter-trace-otlp-grpc": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.53.0.tgz", @@ -18983,21 +17769,8 @@ "requires": { "@opentelemetry/core": "1.26.0", "@opentelemetry/resources": "1.26.0", - "@opentelemetry/sdk-trace-base": "1.26.0", - "@opentelemetry/semantic-conventions": "1.27.0" - } - }, - "@opentelemetry/instrumentation": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz", - "integrity": "sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==", - "requires": { - "@opentelemetry/api-logs": "0.53.0", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" + "@opentelemetry/sdk-trace-base": "1.26.0", + "@opentelemetry/semantic-conventions": "1.27.0" } }, "@opentelemetry/otlp-exporter-base": { @@ -19100,88 +17873,13 @@ "@opentelemetry/sdk-trace-base": "1.26.0", "semver": "^7.5.2" } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" - }, - "import-in-the-middle": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", - "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", - "requires": { - "acorn": "^8.8.2", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" - } - } - } - }, - "@opentelemetry/sdk-trace-base": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.25.1.tgz", - "integrity": "sha512-C8k4hnEbc5FamuZQ92nTOp8X/diCY56XUTnMiv9UTuJitCzaNNHAVsdm5+HLCdI8SLQsLWIrG38tddMxLVoftw==", - "requires": { - "@opentelemetry/core": "1.25.1", - "@opentelemetry/resources": "1.25.1", - "@opentelemetry/semantic-conventions": "1.25.1" - }, - "dependencies": { - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" - } - } - }, - "@opentelemetry/sdk-trace-node": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.25.1.tgz", - "integrity": "sha512-nMcjFIKxnFqoez4gUmihdBrbpsEnAX/Xj16sGvZm+guceYE0NE00vLhpDVK6f3q8Q4VFI5xG8JjlXKMB/SkTTQ==", - "requires": { - "@opentelemetry/context-async-hooks": "1.25.1", - "@opentelemetry/core": "1.25.1", - "@opentelemetry/propagator-b3": "1.25.1", - "@opentelemetry/propagator-jaeger": "1.25.1", - "@opentelemetry/sdk-trace-base": "1.25.1", - "semver": "^7.5.2" - }, - "dependencies": { - "@opentelemetry/context-async-hooks": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.25.1.tgz", - "integrity": "sha512-UW/ge9zjvAEmRWVapOP0qyCvPulWU6cQxGxDbWEFfGOj1VBBZAuOqTo3X6yWmDTD3Xe15ysCZChHncr2xFMIfQ==", - "requires": {} - }, - "@opentelemetry/core": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.25.1.tgz", - "integrity": "sha512-GeT/l6rBYWVQ4XArluLVB6WWQ8flHbdb6r2FCHC3smtdOAbrJBIv35tpV/yp9bmYUJf+xmZpu9DRTIeJVhFbEQ==", - "requires": { - "@opentelemetry/semantic-conventions": "1.25.1" - } - }, - "@opentelemetry/semantic-conventions": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.1.tgz", - "integrity": "sha512-ZDjMJJQRlyk8A1KZFCc+bCbsyrn1wTwdNt56F7twdfUfnHUZUq77/WfONCj8p72NZOyP7pNTdUWSTYC3GTbuuQ==" } } }, "@opentelemetry/semantic-conventions": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.25.0.tgz", - "integrity": "sha512-M+kkXKRAIAiAP6qYyesfrC5TOmDpDVtsxuGfPcqd9B/iBrac+E14jYwrgm0yZBUIbIP2OnqC3j+UgkXLm1vxUQ==" + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", + "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==" }, "@opentelemetry/sql-common": { "version": "0.40.1", @@ -19192,9 +17890,9 @@ } }, "@photostructure/tz-lookup": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-10.0.0.tgz", - "integrity": "sha512-8ZAjoj/irCuvUlyEinQ/HB6A8hP3bD1dgTOZvfl1b9nAwqniutFDHOQRcGM6Crea68bOwPj010f0Z4KkmuLHEA==" + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.0.0.tgz", + "integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw==" }, "@pkgjs/parseargs": { "version": "0.11.0", @@ -19280,9 +17978,9 @@ "requires": {} }, "@react-email/code-block": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.8.tgz", - "integrity": "sha512-WbuAEpTnB262i9C3SGPmmErgZ4iU5KIpqLUjr7uBJijqldLqZc5x39e8wPWaRdF7NLcShmrc/+G7GJgI1bdC5w==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.9.tgz", + "integrity": "sha512-Zrhc71VYrSC1fVXJuaViKoB/dBjxLw6nbE53Bm/eUuZPdnnZ1+ZUIh8jfaRKC5MzMjgnLGQTweGXVnfIrhyxtQ==", "requires": { "prismjs": "1.29.0" } @@ -19300,13 +17998,13 @@ "requires": {} }, "@react-email/components": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.24.tgz", - "integrity": "sha512-/DNmfTREaT59UFdkHoIK3BewJ214LfRxmduiil3m7POj+gougkItANu1+BMmgbUATxjf7jH1WoBxo9x/rhFEFw==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.25.tgz", + "integrity": "sha512-lnfVVrThEcET5NPoeaXvrz9UxtWpGRcut2a07dLbyKgNbP7vj/cXTI5TuHtanCvhCddFpMDnElNRghDOfPzwUg==", "requires": { "@react-email/body": "0.0.10", "@react-email/button": "0.0.17", - "@react-email/code-block": "0.0.8", + "@react-email/code-block": "0.0.9", "@react-email/code-inline": "0.0.4", "@react-email/column": "0.0.12", "@react-email/container": "0.0.14", @@ -19448,114 +18146,114 @@ } }, "@rollup/rollup-android-arm-eabi": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.3.tgz", - "integrity": "sha512-X9alQ3XM6I9IlSlmC8ddAvMSyG1WuHk5oUnXGw+yUBs3BFoTizmG1La/Gr8fVJvDWAq+zlYTZ9DBgrlKRVY06g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "dev": true, "optional": true }, "@rollup/rollup-android-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.3.tgz", - "integrity": "sha512-eQK5JIi+POhFpzk+LnjKIy4Ks+pwJ+NXmPxOCSvOKSNRPONzKuUvWE+P9JxGZVxrtzm6BAYMaL50FFuPe0oWMQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "dev": true, "optional": true }, "@rollup/rollup-darwin-arm64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.3.tgz", - "integrity": "sha512-Od4vE6f6CTT53yM1jgcLqNfItTsLt5zE46fdPaEmeFHvPs5SjZYlLpHrSiHEKR1+HdRfxuzXHjDOIxQyC3ptBA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "dev": true, "optional": true }, "@rollup/rollup-darwin-x64": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.3.tgz", - "integrity": "sha512-0IMAO21axJeNIrvS9lSe/PGthc8ZUS+zC53O0VhF5gMxfmcKAP4ESkKOCwEi6u2asUrt4mQv2rjY8QseIEb1aw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.3.tgz", - "integrity": "sha512-ge2DC7tHRHa3caVEoSbPRJpq7azhG+xYsd6u2MEnJ6XzPSzQsTKyXvh6iWjXRf7Rt9ykIUWHtl0Uz3T6yXPpKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm-musleabihf": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.14.3.tgz", - "integrity": "sha512-ljcuiDI4V3ySuc7eSk4lQ9wU8J8r8KrOUvB2U+TtK0TiW6OFDmJ+DdIjjwZHIw9CNxzbmXY39wwpzYuFDwNXuw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.3.tgz", - "integrity": "sha512-Eci2us9VTHm1eSyn5/eEpaC7eP/mp5n46gTRB3Aar3BgSvDQGJZuicyq6TsH4HngNBgVqC5sDYxOzTExSU+NjA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "dev": true, "optional": true }, "@rollup/rollup-linux-arm64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.3.tgz", - "integrity": "sha512-UrBoMLCq4E92/LCqlh+blpqMz5h1tJttPIniwUgOFJyjWI1qrtrDhhpHPuFxULlUmjFHfloWdixtDhSxJt5iKw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "dev": true, "optional": true }, "@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.3.tgz", - "integrity": "sha512-5aRjvsS8q1nWN8AoRfrq5+9IflC3P1leMoy4r2WjXyFqf3qcqsxRCfxtZIV58tCxd+Yv7WELPcO9mY9aeQyAmw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "dev": true, "optional": true }, "@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.3.tgz", - "integrity": "sha512-sk/Qh1j2/RJSX7FhEpJn8n0ndxy/uf0kI/9Zc4b1ELhqULVdTfN6HL31CDaTChiBAOgLcsJ1sgVZjWv8XNEsAQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "dev": true, "optional": true }, "@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.3.tgz", - "integrity": "sha512-jOO/PEaDitOmY9TgkxF/TQIjXySQe5KVYB57H/8LRP/ux0ZoO8cSHCX17asMSv3ruwslXW/TLBcxyaUzGRHcqg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-gnu": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.3.tgz", - "integrity": "sha512-8ybV4Xjy59xLMyWo3GCfEGqtKV5M5gCSrZlxkPGvEPCGDLNla7v48S662HSGwRd6/2cSneMQWiv+QzcttLrrOA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "dev": true, "optional": true }, "@rollup/rollup-linux-x64-musl": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.3.tgz", - "integrity": "sha512-s+xf1I46trOY10OqAtZ5Rm6lzHre/UiLA1J2uOhCFXWkbZrJRkYBPO6FhvGfHmdtQ3Bx793MNa7LvoWFAm93bg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "dev": true, "optional": true }, "@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.3.tgz", - "integrity": "sha512-+4h2WrGOYsOumDQ5S2sYNyhVfrue+9tc9XcLWLh+Kw3UOxAvrfOrSMFon60KspcDdytkNDh7K2Vs6eMaYImAZg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "dev": true, "optional": true }, "@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.3.tgz", - "integrity": "sha512-T1l7y/bCeL/kUwh9OD4PQT4aM7Bq43vX05htPJJ46RTI4r5KNt6qJRzAfNfM+OYMNEVBWQzR2Gyk+FXLZfogGw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "dev": true, "optional": true }, "@rollup/rollup-win32-x64-msvc": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.3.tgz", - "integrity": "sha512-/BypzV0H1y1HzgYpxqRaXGBRqfodgoBBCcsrujT6QRcakDQdfU+Lq9PENPh5jB4I44YWq+0C2eHsHya+nZY1sA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "dev": true, "optional": true }, @@ -19607,92 +18305,92 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, "@swc/core": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.21.tgz", - "integrity": "sha512-7/cN0SZ+y2V6e0hsDD8koGR0QVh7Jl3r756bwaHLLSN+kReoUb/yVcLsA8iTn90JLME3DkQK4CPjxDCQiyMXNg==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.26.tgz", + "integrity": "sha512-f5uYFf+TmMQyYIoxkn/evWhNGuUzC730dFwAKGwBVHHVoPyak1/GvJUm6i1SKl+2Hrj9oN0i3WSoWWZ4pgI8lw==", "devOptional": true, "requires": { - "@swc/core-darwin-arm64": "1.7.21", - "@swc/core-darwin-x64": "1.7.21", - "@swc/core-linux-arm-gnueabihf": "1.7.21", - "@swc/core-linux-arm64-gnu": "1.7.21", - "@swc/core-linux-arm64-musl": "1.7.21", - "@swc/core-linux-x64-gnu": "1.7.21", - "@swc/core-linux-x64-musl": "1.7.21", - "@swc/core-win32-arm64-msvc": "1.7.21", - "@swc/core-win32-ia32-msvc": "1.7.21", - "@swc/core-win32-x64-msvc": "1.7.21", + "@swc/core-darwin-arm64": "1.7.26", + "@swc/core-darwin-x64": "1.7.26", + "@swc/core-linux-arm-gnueabihf": "1.7.26", + "@swc/core-linux-arm64-gnu": "1.7.26", + "@swc/core-linux-arm64-musl": "1.7.26", + "@swc/core-linux-x64-gnu": "1.7.26", + "@swc/core-linux-x64-musl": "1.7.26", + "@swc/core-win32-arm64-msvc": "1.7.26", + "@swc/core-win32-ia32-msvc": "1.7.26", + "@swc/core-win32-x64-msvc": "1.7.26", "@swc/counter": "^0.1.3", "@swc/types": "^0.1.12" } }, "@swc/core-darwin-arm64": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.21.tgz", - "integrity": "sha512-hh5uOZ7jWF66z2TRMhhXtWMQkssuPCSIZPy9VHf5KvZ46cX+5UeECDthchYklEVZQyy4Qr6oxfh4qff/5spoMA==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.26.tgz", + "integrity": "sha512-FF3CRYTg6a7ZVW4yT9mesxoVVZTrcSWtmZhxKCYJX9brH4CS/7PRPjAKNk6kzWgWuRoglP7hkjQcd6EpMcZEAw==", "dev": true, "optional": true }, "@swc/core-darwin-x64": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.21.tgz", - "integrity": "sha512-lTsPquqSierQ6jWiWM7NnYXXZGk9zx3NGkPLHjPbcH5BmyiauX0CC/YJYJx7YmS2InRLyALlGmidHkaF4JY28A==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.26.tgz", + "integrity": "sha512-az3cibZdsay2HNKmc4bjf62QVukuiMRh5sfM5kHR/JMTrLyS6vSw7Ihs3UTkZjUxkLTT8ro54LI6sV6sUQUbLQ==", "dev": true, "optional": true }, "@swc/core-linux-arm-gnueabihf": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.21.tgz", - "integrity": "sha512-AgSd0fnSzAqCvWpzzZCq75z62JVGUkkXEOpfdi99jj/tryPy38KdXJtkVWJmufPXlRHokGTBitalk33WDJwsbA==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.26.tgz", + "integrity": "sha512-VYPFVJDO5zT5U3RpCdHE5v1gz4mmR8BfHecUZTmD2v1JeFY6fv9KArJUpjrHEEsjK/ucXkQFmJ0jaiWXmpOV9Q==", "dev": true, "optional": true }, "@swc/core-linux-arm64-gnu": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.21.tgz", - "integrity": "sha512-l+jw6RQ4Y43/8dIst0c73uQE+W3kCWrCFqMqC/xIuE/iqHOnvYK6YbA1ffOct2dImkHzNiKuoehGqtQAc6cNaQ==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.26.tgz", + "integrity": "sha512-YKevOV7abpjcAzXrhsl+W48Z9mZvgoVs2eP5nY+uoMAdP2b3GxC0Df1Co0I90o2lkzO4jYBpTMcZlmUXLdXn+Q==", "dev": true, "optional": true }, "@swc/core-linux-arm64-musl": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.21.tgz", - "integrity": "sha512-29KKZXrTo/c9F1JFL9WsNvCa6UCdIVhHP5EfuYhlKbn5/YmSsNFkuHdUtZFEd5U4+jiShXDmgGCtLW2d08LIwg==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.26.tgz", + "integrity": "sha512-3w8iZICMkQQON0uIcvz7+Q1MPOW6hJ4O5ETjA0LSP/tuKqx30hIniCGOgPDnv3UTMruLUnQbtBwVCZTBKR3Rkg==", "dev": true, "optional": true }, "@swc/core-linux-x64-gnu": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.21.tgz", - "integrity": "sha512-HsP3JwddvQj5HvnjmOr+Bd5plEm6ccpfP5wUlm3hywzvdVkj+yR29bmD7UwpV/1zCQ60Ry35a7mXhKI6HQxFgw==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.26.tgz", + "integrity": "sha512-c+pp9Zkk2lqb06bNGkR2Looxrs7FtGDMA4/aHjZcCqATgp348hOKH5WPvNLBl+yPrISuWjbKDVn3NgAvfvpH4w==", "dev": true, "optional": true }, "@swc/core-linux-x64-musl": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.21.tgz", - "integrity": "sha512-hYKLVeUTHqvFK628DFJEwxoX6p42T3HaQ4QjNtf3oKhiJWFh9iTRUrN/oCB5YI3R9WMkFkKh+99gZ/Dd0T5lsg==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.26.tgz", + "integrity": "sha512-PgtyfHBF6xG87dUSSdTJHwZ3/8vWZfNIXQV2GlwEpslrOkGqy+WaiiyE7Of7z9AvDILfBBBcJvJ/r8u980wAfQ==", "dev": true, "optional": true }, "@swc/core-win32-arm64-msvc": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.21.tgz", - "integrity": "sha512-qyWAKW10aMBe6iUqeZ7NAJIswjfggVTUpDINpQGUJhz+pR71YZDidXgZXpaDB84YyDB2JAlRqd1YrLkl7CMiIw==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.26.tgz", + "integrity": "sha512-9TNXPIJqFynlAOrRD6tUQjMq7KApSklK3R/tXgIxc7Qx+lWu8hlDQ/kVPLpU7PWvMMwC/3hKBW+p5f+Tms1hmA==", "dev": true, "optional": true }, "@swc/core-win32-ia32-msvc": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.21.tgz", - "integrity": "sha512-cy61wS3wgH5mEwBiQ5w6/FnQrchBDAdPsSh0dKSzNmI+4K8hDxS8uzdBycWqJXO0cc+mA77SIlwZC3hP3Kum2g==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.26.tgz", + "integrity": "sha512-9YngxNcG3177GYdsTum4V98Re+TlCeJEP4kEwEg9EagT5s3YejYdKwVAkAsJszzkXuyRDdnHUpYbTrPG6FiXrQ==", "dev": true, "optional": true }, "@swc/core-win32-x64-msvc": { - "version": "1.7.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.21.tgz", - "integrity": "sha512-/rexGItJURNJOdae+a48M+loT74nsEU+PyRRVAkZMKNRtLoYFAr0cpDlS5FodIgGunp/nqM0bst4H2w6Y05IKA==", + "version": "1.7.26", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.26.tgz", + "integrity": "sha512-VR+hzg9XqucgLjXxA13MtV5O3C0bK0ywtLIBw/+a+O+Oc6mxFWHtdUeXDbIi5AiPbn0fjgVJMqYnyjGyyX8u0w==", "dev": true, "optional": true }, @@ -19720,12 +18418,12 @@ } }, "@testcontainers/postgresql": { - "version": "10.12.0", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.12.0.tgz", - "integrity": "sha512-n0Q0Btx0R923CDgm6KBXbesPH10CewpuuCPcnmEZzon3IneMzdk4UqVhhNNOeJFDGhtFrZBOoJ1o7CUI4J0vQw==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.13.2.tgz", + "integrity": "sha512-xd3u/rL8FrOBHFMu1aU+2d4sqPz9ffEb19ITtopT/tyBZWW9qCsgR6wSg0r2BJUd+2hT4UR5nR5cymi+ROkehw==", "dev": true, "requires": { - "testcontainers": "^10.12.0" + "testcontainers": "^10.13.2" } }, "@tsconfig/node10": { @@ -19757,25 +18455,34 @@ "peer": true }, "@turf/boolean-point-in-polygon": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", - "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.1.0.tgz", + "integrity": "sha512-mprVsyIQ+ijWTZwbnO4Jhxu94ZW2M2CheqLiRTsGJy0Ooay9v6Av5/Nl3/Gst7ZVXxPqMeMaFYkSzcTc87AKew==", "requires": { - "@turf/helpers": "^6.5.0", - "@turf/invariant": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@turf/invariant": "^7.1.0", + "@types/geojson": "^7946.0.10", + "point-in-polygon-hao": "^1.1.0", + "tslib": "^2.6.2" } }, "@turf/helpers": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", - "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==" + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz", + "integrity": "sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ==", + "requires": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" + } }, "@turf/invariant": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", - "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.1.0.tgz", + "integrity": "sha512-OCLNqkItBYIP1nE9lJGuIUatWGtQ4rhBKAyTfFu0z8npVzGEYzvguEeof8/6LkKmTTEHW53tCjoEhSSzdRh08Q==", "requires": { - "@turf/helpers": "^6.5.0" + "@turf/helpers": "^7.1.0", + "@types/geojson": "^7946.0.10", + "tslib": "^2.6.2" } }, "@types/archiver": { @@ -19794,9 +18501,9 @@ "dev": true }, "@types/aws-lambda": { - "version": "8.10.122", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.122.tgz", - "integrity": "sha512-vBkIh9AY22kVOCEKo5CJlyCgmSWvasC+SWUxL/x/vOwRobMpI/HG1xp/Ae3AqmSiZeLUbOhW0FCD3ZjqqUxmXw==" + "version": "8.10.143", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.143.tgz", + "integrity": "sha512-u5vzlcR14ge/4pMTTMDQr3MF0wEe38B2F9o84uC4F43vN5DGTy63npRrB6jQhyt+C0lGv4ZfiRcRkqJoZuPnmg==" }, "@types/bcrypt": { "version": "5.0.2", @@ -19887,21 +18594,13 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.3.tgz", "integrity": "sha512-iM/WfkwAhwmPff3wZuPLYiHX18HI24jU8k1ZSH7P8FHwxTjZ2P6CoX2wnF43oprR+YXJM6UUxATkNvyv/JHd+g==", "dev": true, + "optional": true, + "peer": true, "requires": { "@types/estree": "*", "@types/json-schema": "*" } }, - "@types/eslint-scope": { - "version": "3.7.5", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.5.tgz", - "integrity": "sha512-JNvhIEyxVW6EoMIFIvj93ZOywYFatlpu9deeH6eSx6PE3WHYvHaQtmHmQeNw7aA81bYGBPPQqdtBm6b1SsQMmA==", - "dev": true, - "requires": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -19941,6 +18640,11 @@ "@types/node": "*" } }, + "@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" + }, "@types/http-errors": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.2.tgz", @@ -19970,9 +18674,9 @@ "dev": true }, "@types/lodash": { - "version": "4.17.7", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", - "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "version": "4.17.10", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz", + "integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==", "dev": true }, "@types/luxon": { @@ -20019,25 +18723,25 @@ } }, "@types/mysql": { - "version": "2.15.22", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.22.tgz", - "integrity": "sha512-wK1pzsJVVAjYCSZWQoWHziQZbNggXFDUEIGf54g4ZM/ERuP86uGdWeKZWMYlqTPMZfHJJvLPyogXGvCOg87yLQ==", + "version": "2.15.26", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", + "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", "requires": { "@types/node": "*" } }, "@types/node": { - "version": "20.16.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.3.tgz", - "integrity": "sha512-/wdGiWRkMOm53gAsSyFMXFZHbVg7C6CbkrzHNpaHoYfsUWPg7m6ZRKtvQjgvQ9i8WT540a3ydRlRQbxjY30XxQ==", + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "requires": { "undici-types": "~6.19.2" } }, "@types/nodemailer": { - "version": "6.4.15", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", - "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "version": "6.4.16", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz", + "integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==", "dev": true, "requires": { "@types/node": "*" @@ -20050,9 +18754,9 @@ "dev": true }, "@types/pg": { - "version": "8.10.9", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.10.9.tgz", - "integrity": "sha512-UksbANNE/f8w0wOMxVKKIrLCbEMV+oM1uKejmwXr39olg4xqcfBDbXxObJAt6XxHbDa4XTKOlUEcEltXDX+XLQ==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "requires": { "@types/node": "*", "pg-protocol": "*", @@ -20060,15 +18764,15 @@ }, "dependencies": { "pg-types": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.1.tgz", - "integrity": "sha512-hRCSDuLII9/LE3smys1hRHcu5QGcLs9ggT7I/TCs0IE+2Eesxi9+9RWAAwZ0yaGjxoWICF/YHLOEjydGujoJ+g==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", "requires": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", - "postgres-date": "~2.0.1", + "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } @@ -20087,9 +18791,9 @@ } }, "postgres-date": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.0.1.tgz", - "integrity": "sha512-YtMKdsDt5Ojv1wQRvUhnyDJNSr2dGIC96mQVKz7xufp07nfuFONzdaowrMHjlAzY6GDLd4f+LUHHAAM1h4MdUw==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==" }, "postgres-interval": { "version": "3.0.0", @@ -20099,9 +18803,9 @@ } }, "@types/pg-pool": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.4.tgz", - "integrity": "sha512-qZAvkv1K3QbmHHFYSNRYPkRjOWRLBYrL4B9c+wG0GSVGBw0NtJwPcgx/DSddeDJvRGMHCEQ4VMEVfuJ/0gZ3XQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", "requires": { "@types/pg": "*" } @@ -20112,6 +18816,15 @@ "integrity": "sha512-1MRgzpzY0hOp9pW/kLRxeQhUWwil6gnrUYd3oEpeYBqp/FexhaCPv3F8LsYr47gtUU45fO2cm1dbwkSrHEo8Uw==", "dev": true }, + "@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -20131,9 +18844,9 @@ "dev": true }, "@types/react": { - "version": "18.3.4", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.4.tgz", - "integrity": "sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==", + "version": "18.3.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", + "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", "dev": true, "requires": { "@types/prop-types": "*", @@ -20250,16 +18963,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "@typescript-eslint/eslint-plugin": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", - "integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", "dev": true, "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/type-utils": "8.3.0", - "@typescript-eslint/utils": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -20267,54 +18980,54 @@ } }, "@typescript-eslint/parser": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", - "integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", - "integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", "dev": true, "requires": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" } }, "@typescript-eslint/type-utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", - "integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/utils": "8.3.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" } }, "@typescript-eslint/types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", - "integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", - "integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", "dev": true, "requires": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -20344,41 +19057,41 @@ } }, "@typescript-eslint/utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", - "integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0" + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" } }, "@typescript-eslint/visitor-keys": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", - "integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", "dev": true, "requires": { - "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" } }, "@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.2.tgz", + "integrity": "sha512-b7kHrFrs2urS0cOk5N10lttI8UdJ/yP3nB4JYTREvR5o18cR99yPpK4gK8oQgI42BVv0ILWYUSYB7AXkAUDc0g==", "dev": true, "requires": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -20397,44 +19110,66 @@ } }, "@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.2.tgz", + "integrity": "sha512-FEgtlN8mIUSEAAnlvn7mP8vzaWhEaAEvhSXCqrsijM7K6QqjB11qoRZYEd4AKSCDz8p0/+yH5LzhZ47qt+EyPg==", "dev": true, "requires": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, + "@vitest/mocker": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.2.tgz", + "integrity": "sha512-ExElkCGMS13JAJy+812fw1aCv2QO/LBK6CyO4WOPAzLTmve50gydOlWhgdBJPx2ztbADUq3JVI0C5U+bShaeEA==", + "dev": true, + "requires": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "dependencies": { + "magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "dev": true, + "requires": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + } + } + }, "@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.2.tgz", + "integrity": "sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==", "dev": true, "requires": { "tinyrainbow": "^1.2.0" } }, "@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.2.tgz", + "integrity": "sha512-UCsPtvluHO3u7jdoONGjOSil+uON5SSvU9buQh3lP7GgUXHp78guN1wRmZDX4wGK6J10f9NUtP6pO+SFquoMlw==", "dev": true, "requires": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.2.tgz", + "integrity": "sha512-xtAeNsZ++aRIYIUsek7VHzry/9AcxeULlegBvsdLncLmNCR6tR8SRjn8BbDP4naxtccvzTqZ+L1ltZlRCfBZFA==", "dev": true, "requires": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.2", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "dependencies": { @@ -20450,22 +19185,21 @@ } }, "@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.2.tgz", + "integrity": "sha512-GSUi5zoy+abNRJwmFhBDC0yRuVUn8WMlQscvnbbXdKLXX9dE59YbfwXxuJ/mth6eeqIzofU8BB5XDo/Ns/qK2A==", "dev": true, "requires": { "tinyspy": "^3.0.0" } }, "@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.2.tgz", + "integrity": "sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==", "dev": true, "requires": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" } @@ -21031,9 +19765,9 @@ } }, "body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "requires": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -21043,7 +19777,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -21284,9 +20018,9 @@ "dev": true }, "cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==" + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==" }, "class-transformer": { "version": "0.5.1", @@ -21705,11 +20439,11 @@ "integrity": "sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==" }, "debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "deep-eql": { @@ -21966,9 +20700,9 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "end-of-stream": { "version": "1.4.4", @@ -21980,9 +20714,9 @@ } }, "engine.io": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.2.tgz", - "integrity": "sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==", + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "requires": { "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", @@ -21993,7 +20727,7 @@ "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.17.1" } }, "engine.io-parser": { @@ -22002,9 +20736,9 @@ "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==" }, "enhanced-resolve": { - "version": "5.17.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", - "integrity": "sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "requires": { "graceful-fs": "^4.2.4", @@ -22044,34 +20778,34 @@ "dev": true }, "esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", - "dev": true, - "requires": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "escalade": { @@ -22091,19 +20825,23 @@ "dev": true }, "eslint": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz", - "integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", "dev": true, "requires": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.9.1", + "@eslint/js": "9.11.1", + "@eslint/plugin-kit": "^0.2.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -22123,7 +20861,6 @@ "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", @@ -22132,6 +20869,12 @@ "text-table": "^0.2.0" }, "dependencies": { + "@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -22145,9 +20888,9 @@ } }, "eslint-visitor-keys": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", "dev": true }, "glob-parent": { @@ -22308,103 +21051,63 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, - "execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "dependencies": { - "is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true - }, - "mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true - }, - "onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "requires": { - "mimic-fn": "^4.0.0" - } - } - } - }, "exiftool-vendored": { - "version": "28.2.1", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.2.1.tgz", - "integrity": "sha512-D3YsKErr3BbjKeJzUVsv6CVZ+SQNgAJKPFWVLXu0CBtr24FNuE3CZBXWKWysGu0MjzeDCNwQrQI5+bXUFeiYVA==", + "version": "28.3.1", + "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.3.1.tgz", + "integrity": "sha512-S2LNaGNu4wBv6q0f/lvst+6DhQrYgc27oDsTgRvx8dGK/5Z1MK4PyMfKCb5GCeCr/nSTGsRnoJlxxRhO1YkBsA==", "requires": { - "@photostructure/tz-lookup": "^10.0.0", + "@photostructure/tz-lookup": "^11.0.0", "@types/luxon": "^3.4.2", "batch-cluster": "^13.0.0", - "exiftool-vendored.exe": "12.91.0", - "exiftool-vendored.pl": "12.91.0", + "exiftool-vendored.exe": "12.96.0", + "exiftool-vendored.pl": "12.96.0", "he": "^1.2.0", "luxon": "^3.5.0" } }, "exiftool-vendored.exe": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.91.0.tgz", - "integrity": "sha512-nxcoGBaJL/D+Wb0jVe8qwyV8QZpRcCzU0aCKhG0S1XNGWGjJJJ4QV851aobcfDwI4NluFOdqkjTSf32pVijvHg==", + "version": "12.96.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.96.0.tgz", + "integrity": "sha512-pKPN9F/Evw2yyO5/+ml3spbXIqejzOxyF7jEnj8tLU2JPSmIlziPUZ75XIhcPbilX86jVKmuiso7FUDicOg8pQ==", "optional": true }, "exiftool-vendored.pl": { - "version": "12.91.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.91.0.tgz", - "integrity": "sha512-GZMy9+Jiv8/C7R4uYe1kWtXsAaJdgVezTwYa+wDeoqvReHiX2t5uzkCrzWdjo4LGl5mPQkyKhN7/uPLYk5Ak6w==", + "version": "12.96.0", + "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.96.0.tgz", + "integrity": "sha512-v4nGnovAMBsTfOWhwAcOiRiq/8kuJOo3GUMHNpug7Mr4jLz3tmWEo7DdNyOYmpcvWbA6smOTG0SmwsrY8fsW+A==", "optional": true }, "express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", "requires": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -22431,9 +21134,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" } } }, @@ -22547,12 +21250,12 @@ } }, "finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "requires": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -22794,12 +21497,12 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, "geo-tz": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.0.2.tgz", - "integrity": "sha512-NjEzJBzaMhO9C7lFZIsWDkVED7aLxcES3iEZOWJ97dhnDUGhEB8vhW7MaWR+2y4aWvtFV/VyuDi8Y0rUHvm4tw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/geo-tz/-/geo-tz-8.1.1.tgz", + "integrity": "sha512-V6FEJ9UQOHnBD7eAOkJG3gZlc0LjKckRjt56cit6MLKMPF2qQIUBDYb0iUj4kw8l+WUeAu9ytPdSPpcLEELWtw==", "requires": { - "@turf/boolean-point-in-polygon": "^6.5.0", - "@turf/helpers": "^6.5.0", + "@turf/boolean-point-in-polygon": "^7.1.0", + "@turf/helpers": "^7.1.0", "geobuf": "^3.0.2", "pbf": "^3.2.1" } @@ -22819,12 +21522,6 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, - "get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true - }, "get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -22849,12 +21546,6 @@ "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", "dev": true }, - "get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true - }, "glob": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", @@ -22910,9 +21601,9 @@ "dev": true }, "globals": { - "version": "15.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", - "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "version": "15.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.10.0.tgz", + "integrity": "sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ==", "dev": true }, "globrex": { @@ -23075,16 +21766,10 @@ "debug": "4" } }, - "human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true - }, "i18n-iso-countries": { - "version": "7.11.3", - "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.11.3.tgz", - "integrity": "sha512-yxQVzNvxEaspSqNnCbqLvwTZNXXkGydWcSxytJYZYb0KH5pn13fdywuX0vFxmOg57Z8ff416AuKDx6Oqnx+j9w==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.12.0.tgz", + "integrity": "sha512-NDFf5j/raA5JrcPT/NcHP3RUMH7TkdkxQKAKdvDlgb+MS296WJzzqvV0Y5uwavSm7A6oYvBeSV0AxoHdDiHIiw==", "requires": { "diacritics": "1.3.0" } @@ -23118,9 +21803,9 @@ } }, "import-in-the-middle": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.8.0.tgz", - "integrity": "sha512-/xQjze8szLNnJ5rvHSzn+dcVXqCAU6Plbk4P24U/jwPmg1wy7IIp9OjKIO5tYue8GSPhDpPDiApQjvBUmWwhsQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.11.0.tgz", + "integrity": "sha512-5DimNQGoe0pLUHbR9qK84iWaWjjbsxiqXnw6Qz64+azRgleqv9k2kTt5fw7QsOpmaGYtuxxursnPPsnTKEx10Q==", "requires": { "acorn": "^8.8.2", "acorn-import-attributes": "^1.9.5", @@ -23368,9 +22053,9 @@ } }, "jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==" + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" }, "js-beautify": { "version": "1.15.1", @@ -23621,13 +22306,10 @@ } }, "loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", - "dev": true, - "requires": { - "get-func-name": "^2.0.1" - } + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true }, "lru-cache": { "version": "5.1.1", @@ -23712,9 +22394,9 @@ } }, "merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==" }, "merge-stream": { "version": "2.0.0", @@ -23733,11 +22415,11 @@ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "dependencies": { @@ -23834,9 +22516,9 @@ "dev": true }, "mock-fs": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz", - "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.3.0.tgz", + "integrity": "sha512-IMvz1X+RF7vf+ur7qUenXMR7/FSKSIqS3HqFHXcyNI7G0FbpFO8L5lfsUJhl+bhK1AiulVHWKUSxebWauPA+xQ==", "dev": true }, "module-details-from-path": { @@ -23856,9 +22538,9 @@ "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==" }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "msgpackr": { "version": "1.10.1", @@ -24112,9 +22794,9 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, "nodemailer": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", - "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==" + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==" }, "nopt": { "version": "5.0.0", @@ -24154,23 +22836,6 @@ "resolved": "https://registry.npmjs.org/notepack.io/-/notepack.io-3.0.1.tgz", "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==" }, - "npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "requires": { - "path-key": "^4.0.0" - }, - "dependencies": { - "path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true - } - } - }, "npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -24193,9 +22858,9 @@ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" }, "object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==" }, "obuf": { "version": "1.1.2", @@ -24237,11 +22902,11 @@ } }, "openid-client": { - "version": "5.6.5", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", - "integrity": "sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.0.tgz", + "integrity": "sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==", "requires": { - "jose": "^4.15.5", + "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" @@ -24431,9 +23096,9 @@ } }, "path-to-regexp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.2.0.tgz", - "integrity": "sha512-jczvQbCUS7XmS7o+y1aEO9OBVFeZBQ1MDSEqmO7xSoPgOPoowY/SxLpZ6Vh97/8qHZOteiCKb7gkG9gA2ZUxJA==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==" }, "path-type": { "version": "4.0.0", @@ -24467,14 +23132,14 @@ "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==" }, "pg": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", - "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.0.tgz", + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", "requires": { "pg-cloudflare": "^1.1.1", - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" } @@ -24486,9 +23151,9 @@ "optional": true }, "pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" }, "pg-int8": { "version": "1.0.1", @@ -24501,15 +23166,15 @@ "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==" }, "pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "requires": {} }, "pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "pg-types": { "version": "2.2.0", @@ -24532,9 +23197,9 @@ } }, "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "picomatch": { "version": "4.0.2", @@ -24559,14 +23224,25 @@ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true }, + "pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true + }, + "point-in-polygon-hao": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.1.0.tgz", + "integrity": "sha512-3hTIM2j/v9Lio+wOyur3kckD4NxruZhpowUbEgmyikW+a2Kppjtu1eN+AhnMQtoHW46zld88JiYWv6fxpsDrTQ==" + }, "postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "requires": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" } }, "postcss-import": { @@ -24656,9 +23332,9 @@ } }, "postgres-range": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.3.tgz", - "integrity": "sha512-VdlZoocy5lCP0c/t66xAfclglEapXPCIVhqqJRncYpvbCgImF0w67aPKfbqUMr72tO2k5q0TdTZwCLjPTI6C9g==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" }, "prelude-ls": { "version": "1.2.1", @@ -24681,9 +23357,9 @@ } }, "prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "requires": {} }, @@ -24793,11 +23469,11 @@ "dev": true }, "qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "requires": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" } }, "queue-microtask": { @@ -25455,27 +24131,27 @@ } }, "rollup": { - "version": "4.14.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.3.tgz", - "integrity": "sha512-ag5tTQKYsj1bhrFC9+OEWqb5O6VYgtQDO9hPDBMmIbePwhfSr+ExlcU741t8Dhw5DkPCQf6noz0jb36D6W9/hw==", - "dev": true, - "requires": { - "@rollup/rollup-android-arm-eabi": "4.14.3", - "@rollup/rollup-android-arm64": "4.14.3", - "@rollup/rollup-darwin-arm64": "4.14.3", - "@rollup/rollup-darwin-x64": "4.14.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.3", - "@rollup/rollup-linux-arm-musleabihf": "4.14.3", - "@rollup/rollup-linux-arm64-gnu": "4.14.3", - "@rollup/rollup-linux-arm64-musl": "4.14.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.3", - "@rollup/rollup-linux-riscv64-gnu": "4.14.3", - "@rollup/rollup-linux-s390x-gnu": "4.14.3", - "@rollup/rollup-linux-x64-gnu": "4.14.3", - "@rollup/rollup-linux-x64-musl": "4.14.3", - "@rollup/rollup-win32-arm64-msvc": "4.14.3", - "@rollup/rollup-win32-ia32-msvc": "4.14.3", - "@rollup/rollup-win32-x64-msvc": "4.14.3", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "@types/estree": "1.0.5", "fsevents": "~2.3.2" } @@ -25580,9 +24256,9 @@ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==" }, "send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "requires": { "debug": "2.6.9", "depd": "2.0.0", @@ -25614,10 +24290,10 @@ } } }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" } } }, @@ -25631,14 +24307,14 @@ } }, "serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "requires": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" } }, "set-blocking": { @@ -25741,13 +24417,14 @@ "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" }, "side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "requires": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" } }, "siginfo": { @@ -25812,14 +24489,6 @@ "requires": { "debug": "~4.3.4", "ws": "~8.17.1" - }, - "dependencies": { - "ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "requires": {} - } } }, "socket.io-parser": { @@ -25838,9 +24507,9 @@ "dev": true }, "source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, "source-map-support": { "version": "0.5.21", @@ -25904,9 +24573,9 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, "sql-formatter": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.1.tgz", - "integrity": "sha512-lw/G/emIJ+tVspOtOFzfD2YFFMN3MFPxGnbWl1DlJLB+fsX7X7zMqSRM1SLSn2YuaRJ0lTe7AMknHDqmIW1Y8w==", + "version": "15.4.2", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.2.tgz", + "integrity": "sha512-Pw4aAgfuyml/SHMlhbJhyOv+GR+Z1HNb9sgX3CVBVdN5YNM+v2VWkYJ3NNbYS7cu37GY3vP/PgnwoVynCuXRxg==", "dev": true, "requires": { "argparse": "^2.0.1", @@ -26023,12 +24692,6 @@ "ansi-regex": "^5.0.1" } }, - "strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true - }, "strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -26181,9 +24844,9 @@ "dev": true }, "tar": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", - "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -26319,9 +24982,9 @@ } }, "testcontainers": { - "version": "10.13.0", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.0.tgz", - "integrity": "sha512-SDblQvirbJw1ZpenxaAairGtAesw5XMOCHLbRhTTUBJtBkZJGce8Vx/I8lXQxWIM8HRXsg3HILTHGQvYo4x7wQ==", + "version": "10.13.2", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.2.tgz", + "integrity": "sha512-LfEll+AG/1Ks3n4+IA5lpyBHLiYh/hSfI4+ERa6urwfQscbDU+M2iW1qPQrHQi+xJXQRYy4whyK1IEHdmxWa3Q==", "dev": true, "requires": { "@balena/dockerignore": "^1.0.2", @@ -26395,15 +25058,21 @@ "integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==" }, "tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", "dev": true }, "tinypool": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.0.tgz", - "integrity": "sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz", + "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==", "dev": true }, "tinyrainbow": { @@ -26413,9 +25082,9 @@ "dev": true }, "tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true }, "tmp": { @@ -26647,15 +25316,15 @@ } }, "typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "devOptional": true }, "ua-parser-js": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.38.tgz", - "integrity": "sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==" + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==" }, "uglify-js": { "version": "3.17.4", @@ -26808,27 +25477,26 @@ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vite": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", - "integrity": "sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==", + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", + "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", "dev": true, "requires": { - "esbuild": "^0.20.1", + "esbuild": "^0.21.3", "fsevents": "~2.3.3", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" } }, "vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.2.tgz", + "integrity": "sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==", "dev": true, "requires": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" } }, @@ -26844,29 +25512,29 @@ } }, "vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, "requires": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.2", + "@vitest/mocker": "2.1.2", + "@vitest/pretty-format": "^2.1.2", + "@vitest/runner": "2.1.2", + "@vitest/snapshot": "2.1.2", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "dependencies": { @@ -26905,12 +25573,11 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webpack": { - "version": "5.93.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.93.0.tgz", - "integrity": "sha512-Y0m5oEY1LRuwly578VqluorkXbvXKh7U3rLoQCEO04M97ScRr44afGVkI0FQFsXzysk5OgFAxjZAb9rsGQVihA==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "requires": { - "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", "@webassemblyjs/wasm-edit": "^1.12.1", @@ -26919,7 +25586,7 @@ "acorn-import-attributes": "^1.9.5", "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -27038,9 +25705,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "requires": {} }, "xtend": { diff --git a/server/package.json b/server/package.json index 48e873a8f84c6..78922609d99d8 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.114.0", + "version": "1.117.0", "description": "", "author": "", "private": true, @@ -18,10 +18,9 @@ "check": "tsc --noEmit", "check:code": "npm run format && npm run lint && npm run check", "check:all": "npm run check:code && npm run test:cov", - "healthcheck": "node ./dist/utils/healthcheck.js", "test": "vitest", - "test:watch": "vitest --watch", "test:cov": "vitest --coverage", + "test:medium": "vitest --config vitest.config.medium.mjs", "typeorm": "typeorm", "lifecycle": "node ./dist/utils/lifecycle.js", "typeorm:migrations:create": "typeorm migration:create", @@ -46,11 +45,11 @@ "@nestjs/swagger": "^7.1.8", "@nestjs/typeorm": "^10.0.0", "@nestjs/websockets": "^10.2.2", - "@opentelemetry/auto-instrumentations-node": "^0.49.0", + "@opentelemetry/auto-instrumentations-node": "^0.50.0", "@opentelemetry/context-async-hooks": "^1.24.0", "@opentelemetry/exporter-prometheus": "^0.53.0", "@opentelemetry/sdk-node": "^0.53.0", - "@react-email/components": "^0.0.24", + "@react-email/components": "^0.0.25", "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.0", "async-lock": "^1.4.0", @@ -60,7 +59,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "cookie-parser": "^1.4.6", - "exiftool-vendored": "^28.1.0", + "exiftool-vendored": "^28.3.1", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", @@ -109,9 +108,10 @@ "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "@types/nodemailer": "^6.4.14", "@types/picomatch": "^3.0.0", + "@types/pngjs": "^6.0.5", "@types/react": "^18.3.4", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", @@ -125,6 +125,7 @@ "eslint-plugin-unicorn": "^55.0.0", "globals": "^15.9.0", "mock-fs": "^5.2.0", + "pngjs": "^7.0.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^4.0.0", "rimraf": "^6.0.0", @@ -138,6 +139,6 @@ "vitest": "^2.0.5" }, "volta": { - "node": "20.17.0" + "node": "20.18.0" } } diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 9446010127450..3f1e2ba08da07 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -2,10 +2,8 @@ import { BullModule } from '@nestjs/bullmq'; import { Inject, Module, OnModuleDestroy, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; -import _ from 'lodash'; import { ClsModule } from 'nestjs-cls'; import { OpenTelemetryModule } from 'nestjs-otel'; import { commands } from 'src/commands'; @@ -13,6 +11,7 @@ import { bullConfig, bullQueues, clsConfig, immichAppConfig } from 'src/config'; import { controllers } from 'src/controllers'; import { databaseConfig } from 'src/database.config'; import { entities } from 'src/entities'; +import { ImmichWorker } from 'src/enum'; import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthGuard } from 'src/middleware/auth.guard'; @@ -23,7 +22,6 @@ import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; import { repositories } from 'src/repositories'; import { services } from 'src/services'; import { DatabaseService } from 'src/services/database.service'; -import { setupEventHandlers } from 'src/utils/events'; import { otelConfig } from 'src/utils/instrumentation'; const common = [...services, ...repositories]; @@ -42,7 +40,6 @@ const imports = [ BullModule.registerQueue(...bullQueues), ClsModule.forRoot(clsConfig), ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), TypeOrmModule.forRootAsync({ inject: [ModuleRef], @@ -58,54 +55,48 @@ const imports = [ TypeOrmModule.forFeature(entities), ]; -@Module({ - imports: [...imports, ScheduleModule.forRoot()], - controllers: [...controllers], - providers: [...common, ...middleware], -}) -export class ApiModule implements OnModuleInit, OnModuleDestroy { +abstract class BaseModule implements OnModuleInit, OnModuleDestroy { + private get worker() { + return this.getWorker(); + } + constructor( - private moduleRef: ModuleRef, + @Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) {} - - async onModuleInit() { - const items = setupEventHandlers(this.moduleRef); + ) { + logger.setAppName(this.worker); + } - await this.eventRepository.emit('app.bootstrap', 'api'); + abstract getWorker(): ImmichWorker; - this.logger.setContext('EventLoader'); - const eventMap = _.groupBy(items, 'event'); - for (const [event, handlers] of Object.entries(eventMap)) { - for (const { priority, label } of handlers) { - this.logger.verbose(`Added ${event} {${label}${priority ? '' : ', ' + priority}} event`); - } - } + async onModuleInit() { + this.eventRepository.setup({ services }); + await this.eventRepository.emit('app.bootstrap', this.worker); } async onModuleDestroy() { - await this.eventRepository.emit('app.shutdown'); + await this.eventRepository.emit('app.shutdown', this.worker); } } @Module({ - imports: [...imports], - providers: [...common, SchedulerRegistry], + imports: [...imports, ScheduleModule.forRoot()], + controllers: [...controllers], + providers: [...common, ...middleware], }) -export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { - constructor( - private moduleRef: ModuleRef, - @Inject(IEventRepository) private eventRepository: IEventRepository, - ) {} - - async onModuleInit() { - setupEventHandlers(this.moduleRef); - await this.eventRepository.emit('app.bootstrap', 'microservices'); +export class ApiModule extends BaseModule { + getWorker() { + return ImmichWorker.API; } +} - async onModuleDestroy() { - await this.eventRepository.emit('app.shutdown'); +@Module({ + imports: [...imports], + providers: [...common, SchedulerRegistry], +}) +export class MicroservicesModule extends BaseModule { + getWorker() { + return ImmichWorker.MICROSERVICES; } } @@ -114,16 +105,3 @@ export class MicroservicesModule implements OnModuleInit, OnModuleDestroy { providers: [...common, ...commands, SchedulerRegistry], }) export class ImmichAdminModule {} - -@Module({ - imports: [ - ConfigModule.forRoot(immichAppConfig), - EventEmitterModule.forRoot(), - TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature(entities), - OpenTelemetryModule.forRoot(otelConfig), - ], - controllers: [...controllers], - providers: [...common, ...middleware, SchedulerRegistry], -}) -export class AppTestModule {} diff --git a/server/src/utils/healthcheck.ts b/server/src/bin/healthcheck.ts similarity index 67% rename from server/src/utils/healthcheck.ts rename to server/src/bin/healthcheck.ts index 763fce81b4fc0..6de58c2002fef 100644 --- a/server/src/utils/healthcheck.ts +++ b/server/src/bin/healthcheck.ts @@ -1,15 +1,17 @@ #!/usr/bin/env node -const port = Number(process.env.IMMICH_PORT) || 3001; -const controller = new AbortController(); +import { ImmichWorker } from 'src/enum'; +import { ConfigRepository } from 'src/repositories/config.repository'; const main = async () => { - if (!process.env.IMMICH_WORKERS_INCLUDE?.includes('api')) { + const { workers, port } = new ConfigRepository().getEnv(); + if (!workers.includes(ImmichWorker.API)) { process.exit(); } + const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2000); try { - const response = await fetch(`http://localhost:${port}/api/server-info/ping`, { + const response = await fetch(`http://localhost:${port}/api/server/ping`, { signal: controller.signal, }); diff --git a/server/src/bin/sync-open-api.ts b/server/src/bin/sync-open-api.ts index 70e2bb8c359de..d5316b34cf3bc 100644 --- a/server/src/bin/sync-open-api.ts +++ b/server/src/bin/sync-open-api.ts @@ -7,7 +7,7 @@ import { useSwagger } from 'src/utils/misc'; const sync = async () => { const app = await NestFactory.create(ApiModule, { preview: true }); - useSwagger(app, true); + useSwagger(app, { write: true }); await app.close(); }; diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 6bf85d1553c54..92c3cc11032ce 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -1,7 +1,6 @@ #!/usr/bin/env node import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { EventEmitterModule } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -85,7 +84,6 @@ class SqlGenerator { logger: this.sqlLogger, }), TypeOrmModule.forFeature(entities), - EventEmitterModule.forRoot(), OpenTelemetryModule.forRoot(otelConfig), ], providers: [...repositories, AuthService, SchedulerRegistry], diff --git a/server/src/config.ts b/server/src/config.ts index 057c9a69e213c..4fdf23ecc2cb6 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -7,82 +7,21 @@ import { RedisOptions } from 'ioredis'; import Joi, { Root } from 'joi'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { ImmichHeader } from 'src/dtos/auth.dto'; +import { + AudioCodec, + Colorspace, + CQMode, + ImageFormat, + ImmichEnvironment, + LogLevel, + ToneMapping, + TranscodeHWAccel, + TranscodePolicy, + VideoCodec, + VideoContainer, +} from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; - -export enum TranscodePolicy { - ALL = 'all', - OPTIMAL = 'optimal', - BITRATE = 'bitrate', - REQUIRED = 'required', - DISABLED = 'disabled', -} - -export enum TranscodeTarget { - NONE, - AUDIO, - VIDEO, - ALL, -} - -export enum VideoCodec { - H264 = 'h264', - HEVC = 'hevc', - VP9 = 'vp9', - AV1 = 'av1', -} - -export enum AudioCodec { - MP3 = 'mp3', - AAC = 'aac', - LIBOPUS = 'libopus', -} - -export enum VideoContainer { - MOV = 'mov', - MP4 = 'mp4', - OGG = 'ogg', - WEBM = 'webm', -} - -export enum TranscodeHWAccel { - NVENC = 'nvenc', - QSV = 'qsv', - VAAPI = 'vaapi', - RKMPP = 'rkmpp', - DISABLED = 'disabled', -} - -export enum ToneMapping { - HABLE = 'hable', - MOBIUS = 'mobius', - REINHARD = 'reinhard', - DISABLED = 'disabled', -} - -export enum CQMode { - AUTO = 'auto', - CQP = 'cqp', - ICQ = 'icq', -} - -export enum Colorspace { - SRGB = 'srgb', - P3 = 'p3', -} - -export enum ImageFormat { - JPEG = 'jpeg', - WEBP = 'webp', -} - -export enum LogLevel { - VERBOSE = 'verbose', - DEBUG = 'debug', - LOG = 'log', - WARN = 'warn', - ERROR = 'error', - FATAL = 'fatal', -} +import { ImageOptions } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -172,11 +111,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnailFormat: ImageFormat; - thumbnailSize: number; - previewFormat: ImageFormat; - previewSize: number; - quality: number; + thumbnail: ImageOptions; + preview: ImageOptions; colorspace: Colorspace; extractEmbedded: boolean; }; @@ -285,8 +221,8 @@ export const defaults = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, @@ -322,11 +258,16 @@ export const defaults = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + }, + preview: { + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, @@ -382,7 +323,10 @@ export const immichAppConfig: ConfigModuleOptions = { envFilePath: '.env', isGlobal: true, validationSchema: Joi.object({ - IMMICH_ENV: Joi.string().optional().valid('development', 'testing', 'production').default('production'), + IMMICH_ENV: Joi.string() + .optional() + .valid(...Object.values(ImmichEnvironment)) + .default(ImmichEnvironment.PRODUCTION), IMMICH_LOG_LEVEL: Joi.string() .optional() .valid(...Object.values(LogLevel)), @@ -464,41 +408,3 @@ export const clsConfig: ClsModuleOptions = { }, }, }; - -export const getBuildMetadata = () => ({ - build: process.env.IMMICH_BUILD, - buildUrl: process.env.IMMICH_BUILD_URL, - buildImage: process.env.IMMICH_BUILD_IMAGE, - buildImageUrl: process.env.IMMICH_BUILD_IMAGE_URL, - repository: process.env.IMMICH_REPOSITORY, - repositoryUrl: process.env.IMMICH_REPOSITORY_URL, - sourceRef: process.env.IMMICH_SOURCE_REF, - sourceCommit: process.env.IMMICH_SOURCE_COMMIT, - sourceUrl: process.env.IMMICH_SOURCE_URL, -}); - -const clientLicensePublicKeyProd = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF2LzdTMzJjUkE1KysxTm5WRHNDTQpzcFAvakpISU1xT0pYRm5oNE53QTJPcHorUk1mZGNvOTJQc09naCt3d1FlRXYxVTJjMnBqelRpUS8ybHJLcS9rCnpKUmxYd2M0Y1Vlc1FETUpPRitQMnFPTlBiQUprWHZDWFlCVUxpdENJa29Md2ZoU0dOanlJS2FSRGhkL3ROeU4KOCtoTlJabllUMWhTSWo5U0NrS3hVQ096YXRQVjRtQ0RlclMrYkUrZ0VVZVdwOTlWOWF6dkYwRkltblRXcFFTdwpjOHdFWmdPTWg0c3ZoNmFpY3dkemtQQ3dFTGFrMFZhQkgzMUJFVUNRTGI5K0FJdEhBVXRKQ0t4aGI1V2pzMXM5CmJyWGZpMHZycGdjWi82RGFuWTJxZlNQem5PbXZEMkZycmxTMXE0SkpOM1ZvN1d3LzBZeS95TWNtelRXWmhHdWgKVVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -const clientLicensePublicKeyStaging = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuSUNyTm5jbGpPSC9JdTNtWVVaRQp0dGJLV1c3OGRuajl5M0U2ekk3dU1NUndEckdYWFhkTGhkUDFxSWtlZHh0clVVeUpCMWR4R04yQW91S082MlNGCldrbU9PTmNGQlRBWFZTdjhUNVY0S0VwWnFQYWEwaXpNaGxMaE5sRXEvY1ZKdllrWlh1Z2x6b1o3cG1nbzFSdHgKam1iRm5NNzhrYTFRUUJqOVdLaEw2eWpWRUl2MDdVS0lKWHBNTnNuS2g1V083MjZhYmMzSE9udTlETjY5VnFFRQo3dGZrUnRWNmx2U1NzMkFVMngzT255cHA4ek53b0lPTWRibGsyb09aWWROZzY0Y3l2SzJoU0FlU3NVMFRyOVc5Ckgra0Y5QlNCNlk0QXl0QlVkSmkrK2pMSW5HM2Q5cU9ieFVzTlYrN05mRkF5NjJkL0xNR0xSOC9OUFc0U0s3c0MKRlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -export const getClientLicensePublicKey = (): string => { - if (process.env.IMMICH_ENV === 'production') { - return clientLicensePublicKeyProd; - } - return clientLicensePublicKeyStaging; -}; - -const serverLicensePublicKeyProd = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvcG5ZRGEwYS9kVTVJZUc3NGlFRQpNd2RBS2pzTmN6TGRDcVJkMVo5eTVUMndqTzdlWUlPZUpUc2wzNTBzUjBwNEtmU1VEU1h2QzlOcERwYzF0T0tsCjVzaEMvQXhwdlFBTENva0Y0anQ4dnJyZDlmQ2FYYzFUcVJiT21uaGl1Z0Q2dmtyME8vRmIzVURpM1UwVHZoUFAKbFBkdlNhd3pMcldaUExmbUhWVnJiclNLbW45SWVTZ3kwN3VrV1RJeUxzY2lOcnZuQnl3c0phUmVEdW9OV1BCSApVL21vMm1YYThtNHdNV2hpWGVoaUlPUXFNdVNVZ1BlQ3NXajhVVngxQ0dsUnpQREEwYlZOUXZlS1hXVnhjRUk2ClVMRWdKeTJGNDlsSDArYVlDbUJmN05FcjZWUTJXQjk1ZXZUS1hLdm4wcUlNN25nRmxjVUF3NmZ1VjFjTkNUSlMKNndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -const serverLicensePublicKeyStaging = - 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE3Sy8yd3ZLUS9NdU8ydi9MUm5saAoyUy9zTHhDOGJiTEw1UUlKOGowQ3BVZW40YURlY2dYMUpKUmtGNlpUVUtpNTdTbEhtS3RSM2JOTzJmdTBUUVg5Ck5WMEJzVzllZVB0MmlTMWl4VVFmTzRObjdvTjZzbEtac01qd29RNGtGRGFmM3VHTlZJc0dMb3UxVWRLUVhpeDEKUlRHcXVTb3NZVjNWRlk3Q1hGYTVWaENBL3poVXNsNGFuVXp3eEF6M01jUFVlTXBaenYvbVZiQlRKVzBPSytWZgpWQUJvMXdYMkVBanpBekVHVzQ3Vko4czhnMnQrNHNPaHFBNStMQjBKVzlORUg5QUpweGZzWE4zSzVtM00yNUJVClZXcTlRYStIdHRENnJ0bnAvcUFweXVkWUdwZk9HYTRCUlZTR1MxMURZM0xrb2FlRzYwUEU5NHpoYjduOHpMWkgKelFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; - -export const getServerLicensePublicKey = (): string => { - if (process.env.IMMICH_ENV === 'production') { - return serverLicensePublicKeyProd; - } - return serverLicensePublicKeyStaging; -}; diff --git a/server/src/constants.ts b/server/src/constants.ts index 6cfcc41d89ba6..5317d5e13c57a 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -1,6 +1,5 @@ import { Duration } from 'luxon'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; import { SemVer } from 'semver'; export const POSTGRES_VERSION_RANGE = '>=14.0.0'; @@ -20,45 +19,15 @@ export const serverVersion = new SemVer(version); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 }); -export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase(); -export const isDev = () => process.env.IMMICH_ENV === 'development'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; -export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283'; export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT; export const citiesFile = 'cities500.txt'; -const buildFolder = process.env.IMMICH_BUILD_DATA || '/build'; - -const folders = { - geodata: join(buildFolder, 'geodata'), - web: join(buildFolder, 'www'), -}; - -export const resourcePaths = { - lockFile: join(buildFolder, 'build-lock.json'), - geodata: { - dateFile: join(folders.geodata, 'geodata-date.txt'), - admin1: join(folders.geodata, 'admin1CodesASCII.txt'), - admin2: join(folders.geodata, 'admin2Codes.txt'), - cities500: join(folders.geodata, citiesFile), - naturalEarthCountriesPath: join(folders.geodata, 'ne_10m_admin_0_countries.geojson'), - }, - web: { - root: folders.web, - indexHtml: join(folders.web, 'index.html'), - }, -}; - export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; export const LOGIN_URL = '/auth/login?autoLaunch=0'; -export enum AuthType { - PASSWORD = 'password', - OAUTH = 'oauth', -} - export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; export const FACE_THUMBNAIL_SIZE = 250; diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index fb5ec58f2544c..b2d3933be4cbc 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -33,16 +33,17 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; import { AssetMediaService } from 'src/services/asset-media.service'; import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(Route.ASSET) +@Controller(RouteKey.ASSET) export class AssetMediaController { constructor( @Inject(ILoggerRepository) private logger: ILoggerRepository, diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index c6fdac1710edc..8a5b5fb0b63a8 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -13,13 +14,13 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; +import { RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; -import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(Route.ASSET) +@Controller(RouteKey.ASSET) export class AssetController { constructor(private service: AssetService) {} @@ -31,6 +32,7 @@ export class AssetController { @Get('random') @Authenticated() + @EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise { return this.service.getRandom(auth, dto.count ?? 1); } diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 7dcef9df5f391..04250f530044f 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { AuthType } from 'src/constants'; import { AuthDto, ChangePasswordDto, @@ -13,6 +12,7 @@ import { ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; +import { AuthType } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index ab569d743425a..f10bf601b4e56 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -19,7 +19,6 @@ import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; import { SearchController } from 'src/controllers/search.controller'; -import { ServerInfoController } from 'src/controllers/server-info.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller'; @@ -57,7 +56,6 @@ export const controllers = [ ReportController, SearchController, ServerController, - ServerInfoController, SessionController, SharedLinkController, StackController, diff --git a/server/src/controllers/job.controller.ts b/server/src/controllers/job.controller.ts index 2aa5920fab7b8..7da19e207fce0 100644 --- a/server/src/controllers/job.controller.ts +++ b/server/src/controllers/job.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { JobService } from 'src/services/job.service'; @@ -15,6 +15,12 @@ export class JobController { return this.service.getAllJobsStatus(); } + @Post() + @Authenticated({ admin: true }) + createJob(@Body() dto: JobCreateDto): Promise { + return this.service.create(dto); + } + @Put(':id') @Authenticated({ admin: true }) sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise { diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index a45617fc2a503..b8959ca28875c 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -4,7 +4,6 @@ import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryResponseDto, @@ -43,6 +42,13 @@ export class LibraryController { return this.service.update(id, dto); } + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) + deleteLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.delete(id); + } + @Post(':id/validate') @HttpCode(200) @Authenticated({ admin: true }) @@ -51,13 +57,6 @@ export class LibraryController { return this.service.validate(id, dto); } - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) - deleteLibrary(@Param() { id }: UUIDParamDto): Promise { - return this.service.delete(id); - } - @Get(':id/statistics') @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { @@ -66,15 +65,8 @@ export class LibraryController { @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { - return this.service.queueScan(id, dto); - } - - @Post(':id/removeOffline') - @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ admin: true }) - removeOfflineFiles(@Param() { id }: UUIDParamDto) { - return this.service.queueRemoveOffline(id); + @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + scanLibrary(@Param() { id }: UUIDParamDto) { + return this.service.queueScan(id); } } diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts index d6c26c58a073c..88104e6b588d8 100644 --- a/server/src/controllers/map.controller.ts +++ b/server/src/controllers/map.controller.ts @@ -7,7 +7,6 @@ import { MapReverseGeocodeDto, MapReverseGeocodeResponseDto, } from 'src/dtos/map.dto'; -import { MapThemeDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { MapService } from 'src/services/map.service'; @@ -22,12 +21,6 @@ export class MapController { return this.service.getMapMarkers(auth, options); } - @Authenticated({ sharedLink: true }) - @Get('style.json') - getMapStyle(@Query() dto: MapThemeDto) { - return this.service.getMapStyle(dto.theme); - } - @Authenticated() @Get('reverse-geocode') @HttpCode(HttpStatus.OK) diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index 2772e93b5d816..3dd72dd73a91d 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; +import { TestEmailResponseDto } from 'src/dtos/notification.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { NotificationService } from 'src/services/notification.service'; @@ -13,7 +14,7 @@ export class NotificationController { @Post('test-email') @HttpCode(HttpStatus.OK) @Authenticated({ admin: true }) - sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto) { + sendTestEmail(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise { return this.service.sendTestEmail(auth.user.id, dto); } } diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index b733dc612b227..4e626b10f01b4 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { AuthType } from 'src/constants'; import { AuthDto, ImmichCookie, @@ -11,6 +10,7 @@ import { OAuthConfigDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; +import { AuthType } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie } from 'src/utils/response'; diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 5462305d9f94e..ba9a181c41015 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -1,9 +1,7 @@ import { Body, Controller, Get, Inject, Next, Param, Post, Put, Query, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; -import { EndpointLifecycle } from 'src/decorators'; import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceUpdateDto, @@ -83,13 +81,6 @@ export class PersonController { await sendFile(res, next, () => this.service.getThumbnail(auth, id), this.logger); } - @EndpointLifecycle({ deprecatedAt: 'v1.113.0' }) - @Get(':id/assets') - @Authenticated() - getPersonAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getAssets(auth, id); - } - @Put(':id/reassign') @Authenticated({ permission: Permission.PERSON_REASSIGN }) reassignFaces( diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 5b8c1eeece026..9fdb2746fc1d3 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchPlacesDto, @@ -28,6 +29,13 @@ export class SearchController { return this.service.searchMetadata(auth, dto); } + @Post('random') + @HttpCode(HttpStatus.OK) + @Authenticated() + searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise { + return this.service.searchRandom(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated() diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts deleted file mode 100644 index 245bbbd3475a4..0000000000000 --- a/server/src/controllers/server-info.controller.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { ApiExcludeController, ApiTags } from '@nestjs/swagger'; -import { EndpointLifecycle } from 'src/decorators'; -import { - ServerAboutResponseDto, - ServerConfigDto, - ServerFeaturesDto, - ServerMediaTypesResponseDto, - ServerPingResponse, - ServerStatsResponseDto, - ServerStorageResponseDto, - ServerThemeDto, - ServerVersionResponseDto, -} from 'src/dtos/server.dto'; -import { Authenticated } from 'src/middleware/auth.guard'; -import { ServerService } from 'src/services/server.service'; -import { VersionService } from 'src/services/version.service'; - -@ApiExcludeController() -@ApiTags('Server Info') -@Controller('server-info') -export class ServerInfoController { - constructor( - private service: ServerService, - private versionService: VersionService, - ) {} - - @Get('about') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - @Authenticated() - getAboutInfo(): Promise { - return this.service.getAboutInfo(); - } - - @Get('storage') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - @Authenticated() - getStorage(): Promise { - return this.service.getStorage(); - } - - @Get('ping') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - pingServer(): ServerPingResponse { - return this.service.ping(); - } - - @Get('version') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getServerVersion(): ServerVersionResponseDto { - return this.versionService.getVersion(); - } - - @Get('features') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getServerFeatures(): Promise { - return this.service.getFeatures(); - } - - @Get('theme') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getTheme(): Promise { - return this.service.getTheme(); - } - - @Get('config') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getServerConfig(): Promise { - return this.service.getConfig(); - } - - @Get('statistics') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - @Authenticated({ admin: true }) - getServerStatistics(): Promise { - return this.service.getStatistics(); - } - - @Get('media-types') - @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) - getSupportedMediaTypes(): ServerMediaTypesResponseDto { - return this.service.getSupportedMediaTypes(); - } -} diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 75becfe341615..8327ff6d1d46d 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -10,6 +10,7 @@ import { ServerStatsResponseDto, ServerStorageResponseDto, ServerThemeDto, + ServerVersionHistoryResponseDto, ServerVersionResponseDto, } from 'src/dtos/server.dto'; import { Authenticated } from 'src/middleware/auth.guard'; @@ -46,6 +47,11 @@ export class ServerController { return this.versionService.getVersion(); } + @Get('version-history') + getVersionHistory(): Promise { + return this.versionService.getVersionHistory(); + } + @Get('features') getServerFeatures(): Promise { return this.service.getFeatures(); @@ -58,7 +64,7 @@ export class ServerController { @Get('config') getServerConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('statistics') diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 804c19500facd..f59c8ad66cbeb 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -13,7 +13,7 @@ export class SystemConfigController { @Get() @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) getConfig(): Promise { - return this.service.getConfig(); + return this.service.getSystemConfig(); } @Get('defaults') @@ -25,7 +25,7 @@ export class SystemConfigController { @Put() @Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true }) updateConfig(@Body() dto: SystemConfigDto): Promise { - return this.service.updateConfig(dto); + return this.service.updateSystemConfig(dto); } @Get('storage-template-options') diff --git a/server/src/controllers/trash.controller.ts b/server/src/controllers/trash.controller.ts index 20adbb11bbe7f..dfcdfa6ba243f 100644 --- a/server/src/controllers/trash.controller.ts +++ b/server/src/controllers/trash.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { TrashResponseDto } from 'src/dtos/trash.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TrashService } from 'src/services/trash.service'; @@ -12,23 +13,23 @@ export class TrashController { constructor(private service: TrashService) {} @Post('empty') - @HttpCode(HttpStatus.NO_CONTENT) + @HttpCode(HttpStatus.OK) @Authenticated({ permission: Permission.ASSET_DELETE }) - emptyTrash(@Auth() auth: AuthDto): Promise { + emptyTrash(@Auth() auth: AuthDto): Promise { return this.service.empty(auth); } @Post('restore') - @HttpCode(HttpStatus.NO_CONTENT) + @HttpCode(HttpStatus.OK) @Authenticated({ permission: Permission.ASSET_DELETE }) - restoreTrash(@Auth() auth: AuthDto): Promise { + restoreTrash(@Auth() auth: AuthDto): Promise { return this.service.restore(auth); } @Post('restore/assets') - @HttpCode(HttpStatus.NO_CONTENT) + @HttpCode(HttpStatus.OK) @Authenticated({ permission: Permission.ASSET_DELETE }) - restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { + restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.restoreAssets(auth, dto); } } diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 01b225839080d..10076098d6516 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -21,15 +21,16 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { UserService } from 'src/services/user.service'; import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Users') -@Controller(Route.USER) +@Controller(RouteKey.USER) export class UserController { constructor( private service: UserService, diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index a4d0d06152f50..8e42cd10764de 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,13 +1,11 @@ import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; -import { ImageFormat } from 'src/config'; import { APP_MEDIA_LOCATION } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType, PathType, PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetFileType } from 'src/enum'; +import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -15,14 +13,7 @@ import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { getAssetFiles } from 'src/utils/asset.util'; - -export enum StorageFolder { - ENCODED_VIDEO = 'encoded-video', - LIBRARY = 'library', - UPLOAD = 'upload', - PROFILE = 'profile', - THUMBNAILS = 'thumbs', -} +import { getConfig } from 'src/utils/config'; export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS)); export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO)); @@ -44,21 +35,20 @@ export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDE let instance: StorageCore | null; export class StorageCore { - private configCore; private constructor( private assetRepository: IAssetRepository, + private configRepository: IConfigRepository, private cryptoRepository: ICryptoRepository, private moveRepository: IMoveRepository, private personRepository: IPersonRepository, private storageRepository: IStorageRepository, - systemMetadataRepository: ISystemMetadataRepository, + private systemMetadataRepository: ISystemMetadataRepository, private logger: ILoggerRepository, - ) { - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } + ) {} static create( assetRepository: IAssetRepository, + configRepository: IConfigRepository, cryptoRepository: ICryptoRepository, moveRepository: IMoveRepository, personRepository: IPersonRepository, @@ -69,6 +59,7 @@ export class StorageCore { if (!instance) { instance = new StorageCore( assetRepository, + configRepository, cryptoRepository, moveRepository, personRepository, @@ -258,7 +249,12 @@ export class StorageCore { this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`); return false; } - const config = await this.configCore.getConfig({ withCache: true }); + const repos = { + configRepo: this.configRepository, + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }; + const config = await getConfig(repos, { withCache: true }); if (assetInfo && config.storageTemplate.hashVerificationEnabled) { const { checksum } = assetInfo; const newChecksum = await this.cryptoRepository.hashFile(newPath); @@ -301,7 +297,7 @@ export class StorageCore { return this.assetRepository.update({ id, sidecarPath: newPath }); } case PersonPathType.FACE: { - return this.personRepository.update([{ id, thumbnailPath: newPath }]); + return this.personRepository.update({ id, thumbnailPath: newPath }); } } } diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts deleted file mode 100644 index 7c1434004a437..0000000000000 --- a/server/src/cores/system-config.core.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import AsyncLock from 'async-lock'; -import { plainToInstance } from 'class-transformer'; -import { validate } from 'class-validator'; -import { load as loadYaml } from 'js-yaml'; -import * as _ from 'lodash'; -import { Subject } from 'rxjs'; -import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigDto } from 'src/dtos/system-config.dto'; -import { SystemMetadataKey } from 'src/enum'; -import { DatabaseLock } from 'src/interfaces/database.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { getKeysDeep, unsetDeep } from 'src/utils/misc'; -import { DeepPartial } from 'typeorm'; - -export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; - -let instance: SystemConfigCore | null; - -@Injectable() -export class SystemConfigCore { - private readonly asyncLock = new AsyncLock(); - private config: SystemConfig | null = null; - private lastUpdated: number | null = null; - - config$ = new Subject(); - - private constructor( - private repository: ISystemMetadataRepository, - private logger: ILoggerRepository, - ) {} - - static create(repository: ISystemMetadataRepository, logger: ILoggerRepository) { - if (!instance) { - instance = new SystemConfigCore(repository, logger); - } - return instance; - } - - static reset() { - instance = null; - } - - async getConfig({ withCache }: { withCache: boolean }): Promise { - if (!withCache || !this.config) { - const lastUpdated = this.lastUpdated; - await this.asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { - if (lastUpdated === this.lastUpdated) { - this.config = await this.buildConfig(); - this.lastUpdated = Date.now(); - } - }); - } - - return this.config!; - } - - async updateConfig(newConfig: SystemConfig): Promise { - // get the difference between the new config and the default config - const partialConfig: DeepPartial = {}; - for (const property of getKeysDeep(defaults)) { - const newValue = _.get(newConfig, property); - const isEmpty = newValue === undefined || newValue === null || newValue === ''; - const defaultValue = _.get(defaults, property); - const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); - - if (isEmpty || isEqual) { - continue; - } - - _.set(partialConfig, property, newValue); - } - - await this.repository.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); - - const config = await this.getConfig({ withCache: false }); - this.config$.next(config); - return config; - } - - async refreshConfig() { - const newConfig = await this.getConfig({ withCache: false }); - this.config$.next(newConfig); - } - - isUsingConfigFile() { - return !!process.env.IMMICH_CONFIG_FILE; - } - - private async buildConfig() { - // load partial - const partial = this.isUsingConfigFile() - ? await this.loadFromFile(process.env.IMMICH_CONFIG_FILE as string) - : await this.repository.get(SystemMetadataKey.SYSTEM_CONFIG); - - // merge with defaults - const config = _.cloneDeep(defaults); - for (const property of getKeysDeep(partial)) { - _.set(config, property, _.get(partial, property)); - } - - // check for extra properties - const unknownKeys = _.cloneDeep(config); - for (const property of getKeysDeep(defaults)) { - unsetDeep(unknownKeys, property); - } - - if (!_.isEmpty(unknownKeys)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); - } - - // validate full config - const errors = await validate(plainToInstance(SystemConfigDto, config)); - if (errors.length > 0) { - if (this.isUsingConfigFile()) { - throw new Error(`Invalid value(s) in file: ${errors}`); - } else { - this.logger.error('Validation error', errors); - } - } - - if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { - config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); - } - - if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { - config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); - } - - return config; - } - - private async loadFromFile(filepath: string) { - try { - const file = await this.repository.readFile(filepath); - return loadYaml(file.toString()) as unknown; - } catch (error: Error | any) { - this.logger.error(`Unable to load configuration file: ${filepath}`); - this.logger.error(error); - throw error; - } - } -} diff --git a/server/src/cores/user.core.ts b/server/src/cores/user.core.ts deleted file mode 100644 index 153463a9cc692..0000000000000 --- a/server/src/cores/user.core.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import sanitize from 'sanitize-filename'; -import { SALT_ROUNDS } from 'src/constants'; -import { UserEntity } from 'src/entities/user.entity'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; - -let instance: UserCore | null; - -export class UserCore { - private constructor( - private cryptoRepository: ICryptoRepository, - private userRepository: IUserRepository, - ) {} - - static create(cryptoRepository: ICryptoRepository, userRepository: IUserRepository) { - if (!instance) { - instance = new UserCore(cryptoRepository, userRepository); - } - - return instance; - } - - static reset() { - instance = null; - } - - async createUser(dto: Partial & { email: string }): Promise { - const user = await this.userRepository.getByEmail(dto.email); - if (user) { - throw new BadRequestException('User exists'); - } - - if (!dto.isAdmin) { - const localAdmin = await this.userRepository.getAdmin(); - if (!localAdmin) { - throw new BadRequestException('The first registered account must the administrator.'); - } - } - - const payload: Partial = { ...dto }; - if (payload.password) { - payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); - } - if (payload.storageLabel) { - payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); - } - - return this.userRepository.create(payload); - } -} diff --git a/server/src/database.config.ts b/server/src/database.config.ts index 9cc317a734160..2a46067bc1ce1 100644 --- a/server/src/database.config.ts +++ b/server/src/database.config.ts @@ -1,16 +1,17 @@ -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { DataSource } from 'typeorm'; import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js'; -const url = process.env.DB_URL; +const { database } = new ConfigRepository().getEnv(); +const { url, host, port, username, password, name } = database; const urlOrParts = url ? { url } : { - host: process.env.DB_HOSTNAME || 'database', - port: Number.parseInt(process.env.DB_PORT || '5432'), - username: process.env.DB_USERNAME || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - database: process.env.DB_DATABASE_NAME || 'immich', + host, + port, + username, + password, + database: name, }; /* eslint unicorn/prefer-module: "off" -- We can fix this when migrating to ESM*/ @@ -32,6 +33,3 @@ export const databaseConfig: PostgresConnectionOptions = { * this export is ONLY to be used for TypeORM commands in package.json#scripts */ export const dataSource = new DataSource({ ...databaseConfig, host: 'localhost' }); - -export const getVectorExtension = () => - process.env.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 2316e114e885e..278236823924f 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -1,11 +1,9 @@ import { SetMetadata, applyDecorators } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; -import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; -import { Metadata } from 'src/middleware/auth.guard'; +import { MetadataKey } from 'src/enum'; +import { EmitEvent } from 'src/interfaces/event.interface'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the @@ -133,15 +131,14 @@ export interface GenerateSqlQueries { /** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); -export const OnServerEvent = (event: ServerEvent, options?: OnEventOptions) => - OnEvent(event, { suppressErrors: false, ...options }); - -export type EmitConfig = { - event: EmitEvent; +export type EventConfig = { + name: EmitEvent; + /** handle socket.io server events as well */ + server?: boolean; /** lower value has higher priority, defaults to 0 */ priority?: number; }; -export const OnEmit = (config: EmitConfig) => SetMetadata(Metadata.ON_EMIT_CONFIG, config); +export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/dtos/asset-media-response.dto.ts b/server/src/dtos/asset-media-response.dto.ts index 33fa080bc1607..5cd9b7e7d9b53 100644 --- a/server/src/dtos/asset-media-response.dto.ts +++ b/server/src/dtos/asset-media-response.dto.ts @@ -26,6 +26,7 @@ export class AssetBulkUploadCheckResult { action!: AssetUploadAction; reason?: AssetRejectReason; assetId?: string; + isTrashed?: boolean; } export class AssetBulkUploadCheckResponseDto { diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index e9e346c4cb593..c62857da65042 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -56,9 +56,6 @@ export class AssetMediaCreateDto extends AssetMediaBase { @ValidateBoolean({ optional: true }) isVisible?: boolean; - @ValidateBoolean({ optional: true }) - isOffline?: boolean; - @ValidateUUID({ optional: true }) livePhotoVideoId?: string; diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 5a2fdb51200d7..42d6d7d7451eb 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -68,6 +68,9 @@ export class UpdateAssetDto extends UpdateAssetBase { @Optional() @IsString() description?: string; + + @ValidateUUID({ optional: true, nullable: true }) + livePhotoVideoId?: string | null; } export class RandomAssetsDto { @@ -89,8 +92,9 @@ export class AssetIdsDto { } export enum AssetJobName { - REGENERATE_THUMBNAIL = 'regenerate-thumbnail', + REFRESH_FACES = 'refresh-faces', REFRESH_METADATA = 'refresh-metadata', + REGENERATE_THUMBNAIL = 'regenerate-thumbnail', TRANSCODE_VIDEO = 'transcode-video', } diff --git a/server/src/dtos/audit.dto.ts b/server/src/dtos/audit.dto.ts index dcace5a551213..434da46eba976 100644 --- a/server/src/dtos/audit.dto.ts +++ b/server/src/dtos/audit.dto.ts @@ -1,8 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { AssetPathType, PathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; -import { EntityType } from 'src/enum'; +import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index b7d8cf59bf55a..49e4cfb67b3c8 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ManualJobName } from 'src/enum'; import { JobCommand, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean } from 'src/validation'; @@ -17,7 +18,13 @@ export class JobCommandDto { command!: JobCommand; @ValidateBoolean({ optional: true }) - force!: boolean; + force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit +} + +export class JobCreateDto { + @IsEnum(ManualJobName) + @ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' }) + name!: ManualJobName; } export class JobCountsDto { diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index c2c3ac9d27546..7fb363dd9a5e1 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator'; import { LibraryEntity } from 'src/entities/library.entity'; -import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; +import { Optional, ValidateUUID } from 'src/validation'; export class CreateLibraryDto { @ValidateUUID() @@ -89,14 +89,6 @@ export class LibrarySearchDto { userId?: string; } -export class ScanLibraryDto { - @ValidateBoolean({ optional: true }) - refreshModifiedFiles?: boolean; - - @ValidateBoolean({ optional: true }) - refreshAllFiles?: boolean; -} - export class LibraryResponseDto { id!: string; ownerId!: string; diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts index dffacc793d57b..f8b9e2043f3ea 100644 --- a/server/src/dtos/model-config.dto.ts +++ b/server/src/dtos/model-config.dto.ts @@ -27,14 +27,14 @@ export class DuplicateDetectionConfig extends TaskConfig { export class FacialRecognitionConfig extends ModelConfig { @IsNumber() - @Min(0) + @Min(0.1) @Max(1) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) minScore!: number; @IsNumber() - @Min(0) + @Min(0.1) @Max(2) @Type(() => Number) @ApiProperty({ type: 'number', format: 'double' }) diff --git a/server/src/dtos/notification.dto.ts b/server/src/dtos/notification.dto.ts new file mode 100644 index 0000000000000..34b3923580830 --- /dev/null +++ b/server/src/dtos/notification.dto.ts @@ -0,0 +1,3 @@ +export class TestEmailResponseDto { + messageId!: string; +} diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 9e36cfee800b8..5c5dce1a1190a 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -99,12 +99,6 @@ class BaseSearchDto { @Optional({ nullable: true, emptyToNull: true }) lensModel?: string | null; - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - page?: number; - @IsInt() @Min(1) @Max(1000) @@ -119,7 +113,15 @@ class BaseSearchDto { personIds?: string[]; } -export class MetadataSearchDto extends BaseSearchDto { +export class RandomSearchDto extends BaseSearchDto { + @ValidateBoolean({ optional: true }) + withStacked?: boolean; + + @ValidateBoolean({ optional: true }) + withPeople?: boolean; +} + +export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; @@ -133,12 +135,6 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() checksum?: string; - @ValidateBoolean({ optional: true }) - withStacked?: boolean; - - @ValidateBoolean({ optional: true }) - withPeople?: boolean; - @IsString() @IsNotEmpty() @Optional() @@ -168,12 +164,24 @@ export class MetadataSearchDto extends BaseSearchDto { @Optional() @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SmartSearchDto extends BaseSearchDto { @IsString() @IsNotEmpty() query!: string; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; } export class SearchPlacesDto { diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 78e59e4d1a695..e54048335129f 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -30,6 +30,11 @@ export class ServerAboutResponseDto { exiftool?: string; licensed!: boolean; + + thirdPartySourceUrl?: string; + thirdPartyBugFeatureUrl?: string; + thirdPartyDocumentationUrl?: string; + thirdPartySupportUrl?: string; } export class ServerStorageResponseDto { @@ -63,6 +68,12 @@ export class ServerVersionResponseDto { } } +export class ServerVersionHistoryResponseDto { + id!: string; + createdAt!: Date; + version!: string; +} + export class UsageByUserDto { @ApiProperty({ type: 'string' }) userId!: string; @@ -121,6 +132,8 @@ export class ServerConfigDto { isInitialized!: boolean; isOnboarded!: boolean; externalDomain!: string; + mapDarkStyleUrl!: string; + mapLightStyleUrl!: string; } export class ServerFeaturesDto { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 14027aa16ad32..039dbd20ff36a 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -18,20 +18,20 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; +import { SystemConfig } from 'src/config'; +import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { AudioCodec, CQMode, Colorspace, ImageFormat, LogLevel, - SystemConfig, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, -} from 'src/config'; -import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; +} from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean, validateCronExpression } from 'src/validation'; @@ -296,10 +296,12 @@ class SystemConfigMapDto { @ValidateBoolean() enabled!: boolean; - @IsString() + @IsNotEmpty() + @IsUrl() lightStyle!: string; - @IsString() + @IsNotEmpty() + @IsUrl() darkStyle!: string; } @@ -471,33 +473,35 @@ export class SystemConfigThemeDto { customCss!: string; } -class SystemConfigImageDto { +class SystemConfigGeneratedImageDto { @IsEnum(ImageFormat) @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - thumbnailFormat!: ImageFormat; + format!: ImageFormat; @IsInt() @Min(1) + @Max(100) @Type(() => Number) @ApiProperty({ type: 'integer' }) - thumbnailSize!: number; - - @IsEnum(ImageFormat) - @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - previewFormat!: ImageFormat; + quality!: number; @IsInt() @Min(1) @Type(() => Number) @ApiProperty({ type: 'integer' }) - previewSize!: number; + size!: number; +} - @IsInt() - @Min(1) - @Max(100) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - quality!: number; +export class SystemConfigImageDto { + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + thumbnail!: SystemConfigGeneratedImageDto; + + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + preview!: SystemConfigGeneratedImageDto; @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) diff --git a/server/src/dtos/trash.dto.ts b/server/src/dtos/trash.dto.ts new file mode 100644 index 0000000000000..d8e139bff2858 --- /dev/null +++ b/server/src/dtos/trash.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TrashResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; +} diff --git a/server/src/dtos/user-profile.dto.ts b/server/src/dtos/user-profile.dto.ts index 9659fa39650a3..16eea373e3458 100644 --- a/server/src/dtos/user-profile.dto.ts +++ b/server/src/dtos/user-profile.dto.ts @@ -8,12 +8,6 @@ export class CreateProfileImageDto { export class CreateProfileImageResponseDto { userId!: string; + profileChangedAt!: Date; profileImagePath!: string; } - -export function mapCreateProfileImageResponse(userId: string, profileImagePath: string): CreateProfileImageResponseDto { - return { - userId, - profileImagePath, - }; -} diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index f7cd70ee745c2..36f0b6386f76d 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -32,6 +32,7 @@ export class UserResponseDto { profileImagePath!: string; @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) avatarColor!: UserAvatarColor; + profileChangedAt!: Date; } export class UserLicense { @@ -47,6 +48,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => { name: entity.name, profileImagePath: entity.profileImagePath, avatarColor: getPreferences(entity).avatar.color, + profileChangedAt: entity.profileChangedAt, }; }; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 9ebf9364d1b2a..0b893134d0e0d 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -10,7 +10,7 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { StackEntity } from 'src/entities/stack.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { AssetType } from 'src/enum'; +import { AssetStatus, AssetType } from 'src/enum'; import { Column, CreateDateColumn, @@ -70,6 +70,9 @@ export class AssetEntity { @Column() type!: AssetType; + @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE }) + status!: AssetStatus; + @Column() originalPath!: string; diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 0b7ca8c3bd013..7425ee67d8a6e 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -25,6 +25,7 @@ import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { TagEntity } from 'src/entities/tag.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { VersionHistoryEntity } from 'src/entities/version-history.entity'; export const entities = [ ActivityEntity, @@ -54,4 +55,5 @@ export const entities = [ UserMetadataEntity, SessionEntity, LibraryEntity, + VersionHistoryEntity, ]; diff --git a/server/src/entities/move.entity.ts b/server/src/entities/move.entity.ts index f3dad6b280306..5cdef5d22ef76 100644 --- a/server/src/entities/move.entity.ts +++ b/server/src/entities/move.entity.ts @@ -1,3 +1,4 @@ +import { PathType } from 'src/enum'; import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm'; @Entity('move_history') @@ -21,21 +22,3 @@ export class MoveEntity { @Column({ type: 'varchar' }) newPath!: string; } - -export enum AssetPathType { - ORIGINAL = 'original', - PREVIEW = 'preview', - THUMBNAIL = 'thumbnail', - ENCODED_VIDEO = 'encoded_video', - SIDECAR = 'sidecar', -} - -export enum PersonPathType { - FACE = 'face', -} - -export enum UserPathType { - PROFILE = 'profile', -} - -export type PathType = AssetPathType | PersonPathType | UserPathType; diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 9cacad315ba21..ea446be390844 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -67,4 +67,7 @@ export class UserEntity { @OneToMany(() => UserMetadataEntity, (metadata) => metadata.user) metadata!: UserMetadataEntity[]; + + @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + profileChangedAt!: Date; } diff --git a/server/src/entities/version-history.entity.ts b/server/src/entities/version-history.entity.ts new file mode 100644 index 0000000000000..edccd9aed6118 --- /dev/null +++ b/server/src/entities/version-history.entity.ts @@ -0,0 +1,13 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('version_history') +export class VersionHistoryEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @Column() + version!: string; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index 32254854e4c5a..109e9a90b7eba 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1,3 +1,8 @@ +export enum AuthType { + PASSWORD = 'password', + OAUTH = 'oauth', +} + export enum AssetType { IMAGE = 'IMAGE', VIDEO = 'VIDEO', @@ -148,6 +153,14 @@ export enum SharedLinkType { INDIVIDUAL = 'INDIVIDUAL', } +export enum StorageFolder { + ENCODED_VIDEO = 'encoded-video', + LIBRARY = 'library', + UPLOAD = 'upload', + PROFILE = 'profile', + THUMBNAILS = 'thumbs', +} + export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', FACIAL_RECOGNITION_STATE = 'facial-recognition-state', @@ -182,7 +195,147 @@ export enum UserStatus { DELETED = 'deleted', } +export enum AssetStatus { + ACTIVE = 'active', + TRASHED = 'trashed', + DELETED = 'deleted', +} + export enum SourceType { MACHINE_LEARNING = 'machine-learning', EXIF = 'exif', } + +export enum ManualJobName { + PERSON_CLEANUP = 'person-cleanup', + TAG_CLEANUP = 'tag-cleanup', + USER_CLEANUP = 'user-cleanup', +} + +export enum AssetPathType { + ORIGINAL = 'original', + PREVIEW = 'preview', + THUMBNAIL = 'thumbnail', + ENCODED_VIDEO = 'encoded_video', + SIDECAR = 'sidecar', +} + +export enum PersonPathType { + FACE = 'face', +} + +export enum UserPathType { + PROFILE = 'profile', +} + +export type PathType = AssetPathType | PersonPathType | UserPathType; + +export enum TranscodePolicy { + ALL = 'all', + OPTIMAL = 'optimal', + BITRATE = 'bitrate', + REQUIRED = 'required', + DISABLED = 'disabled', +} + +export enum TranscodeTarget { + NONE, + AUDIO, + VIDEO, + ALL, +} + +export enum VideoCodec { + H264 = 'h264', + HEVC = 'hevc', + VP9 = 'vp9', + AV1 = 'av1', +} + +export enum AudioCodec { + MP3 = 'mp3', + AAC = 'aac', + LIBOPUS = 'libopus', +} + +export enum VideoContainer { + MOV = 'mov', + MP4 = 'mp4', + OGG = 'ogg', + WEBM = 'webm', +} + +export enum TranscodeHWAccel { + NVENC = 'nvenc', + QSV = 'qsv', + VAAPI = 'vaapi', + RKMPP = 'rkmpp', + DISABLED = 'disabled', +} + +export enum ToneMapping { + HABLE = 'hable', + MOBIUS = 'mobius', + REINHARD = 'reinhard', + DISABLED = 'disabled', +} + +export enum CQMode { + AUTO = 'auto', + CQP = 'cqp', + ICQ = 'icq', +} + +export enum Colorspace { + SRGB = 'srgb', + P3 = 'p3', +} + +export enum ImageFormat { + JPEG = 'jpeg', + WEBP = 'webp', +} + +export enum LogLevel { + VERBOSE = 'verbose', + DEBUG = 'debug', + LOG = 'log', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +export enum MetadataKey { + AUTH_ROUTE = 'auth_route', + ADMIN_ROUTE = 'admin_route', + SHARED_ROUTE = 'shared_route', + API_KEY_SECURITY = 'api_key', + EVENT_CONFIG = 'event_config', +} + +export enum RouteKey { + ASSET = 'assets', + USER = 'users', +} + +export enum CacheControl { + PRIVATE_WITH_CACHE = 'private_with_cache', + PRIVATE_WITHOUT_CACHE = 'private_without_cache', + NONE = 'none', +} + +export enum PaginationMode { + LIMIT_OFFSET = 'limit-offset', + SKIP_TAKE = 'skip-take', +} + +export enum ImmichEnvironment { + DEVELOPMENT = 'development', + TESTING = 'testing', + PRODUCTION = 'production', +} + +export enum ImmichWorker { + API = 'api', + MICROSERVICES = 'microservices', +} diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 091442ff0592a..24c64bdc9d2c0 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -16,18 +16,15 @@ export interface AlbumInfoOptions { export interface IAlbumRepository extends IBulkAsset { getById(id: string, options: AlbumInfoOptions): Promise; - getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; removeAsset(assetId: string): Promise; getMetadataForIds(ids: string[]): Promise; - getInvalidThumbnail(): Promise; getOwned(ownerId: string): Promise; getShared(ownerId: string): Promise; getNotShared(ownerId: string): Promise; restoreAll(userId: string): Promise; softDeleteAll(userId: string): Promise; deleteAll(userId: string): Promise; - getAll(): Promise; create(album: Partial): Promise; update(album: Partial): Promise; delete(id: string): Promise; diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 9f7213de82b80..750a85209474c 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,7 +1,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetFileType, AssetOrder, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -36,8 +36,6 @@ export enum WithoutProperty { export enum WithProperty { SIDECAR = 'sidecar', - IS_ONLINE = 'isOnline', - IS_OFFLINE = 'isOffline', } export enum TimeBucketSize { @@ -56,6 +54,7 @@ export interface AssetBuilderOptions { userIds?: string[]; withStacked?: boolean; exifInfo?: boolean; + status?: AssetStatus; assetType?: AssetType; } @@ -142,13 +141,17 @@ export interface AssetUpdateDuplicateOptions { duplicateIds: string[]; } +export interface UpsertFileOptions { + assetId: string; + type: AssetFileType; + path: string; +} + export type AssetPathEntity = Pick; export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { - getAssetsByOriginalPath(userId: string, partialPath: string): Promise; - getUniqueOriginalPaths(userId: string): Promise; create(asset: AssetCreate): Promise; getByIds( ids: string[], @@ -175,10 +178,8 @@ export interface IAssetRepository { libraryId?: string, withDeleted?: boolean, ): Paginated; - getRandom(userId: string, count: number): Promise; - getFirstAssetForAlbumId(albumId: string): Promise; + getRandom(userIds: string[], count: number): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; @@ -188,8 +189,6 @@ export interface IAssetRepository { updateDuplicates(options: AssetUpdateDuplicateOptions): Promise; update(asset: AssetUpdateOptions): Promise; remove(asset: AssetEntity): Promise; - softDeleteAll(ids: string[]): Promise; - restoreAll(ids: string[]): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; getStatistics(ownerId: string, options: AssetStatsOptions): Promise; getTimeBuckets(options: TimeBucketOptions): Promise; @@ -201,5 +200,6 @@ export interface IAssetRepository { getDuplicates(options: AssetBuilderOptions): Promise; getAllForUserFullSync(options: AssetFullSyncOptions): Promise; getChangedDeltaSync(options: AssetDeltaSyncOptions): Promise; - upsertFile(options: { assetId: string; type: AssetFileType; path: string }): Promise; + upsertFile(file: UpsertFileOptions): Promise; + upsertFiles(files: UpsertFileOptions[]): Promise; } diff --git a/server/src/interfaces/config.interface.ts b/server/src/interfaces/config.interface.ts new file mode 100644 index 0000000000000..d105e40cf90d5 --- /dev/null +++ b/server/src/interfaces/config.interface.ts @@ -0,0 +1,71 @@ +import { ImmichEnvironment, ImmichWorker, LogLevel } from 'src/enum'; +import { VectorExtension } from 'src/interfaces/database.interface'; + +export const IConfigRepository = 'IConfigRepository'; + +export interface EnvData { + port: number; + environment: ImmichEnvironment; + configFile?: string; + logLevel?: LogLevel; + + buildMetadata: { + build?: string; + buildUrl?: string; + buildImage?: string; + buildImageUrl?: string; + repository?: string; + repositoryUrl?: string; + sourceRef?: string; + sourceCommit?: string; + sourceUrl?: string; + thirdPartySourceUrl?: string; + thirdPartyBugFeatureUrl?: string; + thirdPartyDocumentationUrl?: string; + thirdPartySupportUrl?: string; + }; + + database: { + url?: string; + host: string; + port: number; + username: string; + password: string; + name: string; + skipMigrations: boolean; + vectorExtension: VectorExtension; + }; + + licensePublicKey: { + client: string; + server: string; + }; + + resourcePaths: { + lockFile: string; + geodata: { + dateFile: string; + admin1: string; + admin2: string; + cities500: string; + naturalEarthCountriesPath: string; + }; + web: { + root: string; + indexHtml: string; + }; + }; + + storage: { + ignoreMountCheckErrors: boolean; + }; + + workers: ImmichWorker[]; + + noColor: boolean; + nodeVersion?: string; +} + +export interface IConfigRepository { + getEnv(): EnvData; +} diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 51b39b95a8c08..e388f354f2ac1 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -17,6 +17,7 @@ export enum DatabaseLock { Migrations = 200, SystemFileMounts = 300, StorageTemplateMigration = 420, + VersionHistory = 500, CLIPDimSize = 512, LibraryWatch = 1337, GetSystemConfig = 69, diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index ec6e776f5992b..7ea48faf5380f 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -1,94 +1,106 @@ +import { ClassConstructor } from 'class-transformer'; import { SystemConfig } from 'src/config'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; +import { ImmichWorker } from 'src/enum'; export const IEventRepository = 'IEventRepository'; -type EmitEventMap = { +type EventMap = { // app events - 'app.bootstrap': ['api' | 'microservices']; - 'app.shutdown': []; + 'app.bootstrap': [ImmichWorker]; + 'app.shutdown': [ImmichWorker]; // config events - 'config.update': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; + 'config.update': [ + { + newConfig: SystemConfig; + /** When the server starts, `oldConfig` is `undefined` */ + oldConfig?: SystemConfig; + }, + ]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; // album events 'album.update': [{ id: string; updatedBy: string }]; 'album.invite': [{ id: string; userId: string }]; - // tag events + // asset events 'asset.tag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }]; + 'asset.hide': [{ assetId: string; userId: string }]; + 'asset.show': [{ assetId: string; userId: string }]; + 'asset.trash': [{ assetId: string; userId: string }]; + 'asset.delete': [{ assetId: string; userId: string }]; + + // asset bulk events + 'assets.trash': [{ assetIds: string[]; userId: string }]; + 'assets.delete': [{ assetIds: string[]; userId: string }]; + 'assets.restore': [{ assetIds: string[]; userId: string }]; // session events 'session.delete': [{ sessionId: string }]; + // stack events + 'stack.create': [{ stackId: string; userId: string }]; + 'stack.update': [{ stackId: string; userId: string }]; + 'stack.delete': [{ stackId: string; userId: string }]; + + // stack bulk events + 'stacks.delete': [{ stackIds: string[]; userId: string }]; + // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; + + // websocket events + 'websocket.connect': [{ userId: string }]; }; -export type EmitEvent = keyof EmitEventMap; +export const serverEvents = ['config.update'] as const; +export type ServerEvents = (typeof serverEvents)[number]; + +export type EmitEvent = keyof EventMap; export type EmitHandler = (...args: ArgsOf) => Promise | void; -export type ArgOf = EmitEventMap[T][0]; -export type ArgsOf = EmitEventMap[T]; - -export enum ClientEvent { - UPLOAD_SUCCESS = 'on_upload_success', - USER_DELETE = 'on_user_delete', - ASSET_DELETE = 'on_asset_delete', - ASSET_TRASH = 'on_asset_trash', - ASSET_UPDATE = 'on_asset_update', - ASSET_HIDDEN = 'on_asset_hidden', - ASSET_RESTORE = 'on_asset_restore', - ASSET_STACK_UPDATE = 'on_asset_stack_update', - PERSON_THUMBNAIL = 'on_person_thumbnail', - SERVER_VERSION = 'on_server_version', - CONFIG_UPDATE = 'on_config_update', - NEW_RELEASE = 'on_new_release', - SESSION_DELETE = 'on_session_delete', -} +export type ArgOf = EventMap[T][0]; +export type ArgsOf = EventMap[T]; export interface ClientEventMap { - [ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto; - [ClientEvent.USER_DELETE]: string; - [ClientEvent.ASSET_DELETE]: string; - [ClientEvent.ASSET_TRASH]: string[]; - [ClientEvent.ASSET_UPDATE]: AssetResponseDto; - [ClientEvent.ASSET_HIDDEN]: string; - [ClientEvent.ASSET_RESTORE]: string[]; - [ClientEvent.ASSET_STACK_UPDATE]: string[]; - [ClientEvent.PERSON_THUMBNAIL]: string; - [ClientEvent.SERVER_VERSION]: ServerVersionResponseDto; - [ClientEvent.CONFIG_UPDATE]: Record; - [ClientEvent.NEW_RELEASE]: ReleaseNotification; - [ClientEvent.SESSION_DELETE]: string; -} - -export enum ServerEvent { - CONFIG_UPDATE = 'config.update', - WEBSOCKET_CONNECT = 'websocket.connect', + on_upload_success: [AssetResponseDto]; + on_user_delete: [string]; + on_asset_delete: [string]; + on_asset_trash: [string[]]; + on_asset_update: [AssetResponseDto]; + on_asset_hidden: [string]; + on_asset_restore: [string[]]; + on_asset_stack_update: string[]; + on_person_thumbnail: [string]; + on_server_version: [ServerVersionResponseDto]; + on_config_update: []; + on_new_release: [ReleaseNotification]; + on_session_delete: [string]; } -export interface ServerEventMap { - [ServerEvent.CONFIG_UPDATE]: null; - [ServerEvent.WEBSOCKET_CONNECT]: { userId: string }; -} +export type EventItem = { + event: T; + handler: EmitHandler; + server: boolean; +}; export interface IEventRepository { - on(event: T, handler: EmitHandler): void; - emit(event: T, ...args: ArgsOf): Promise; + setup(options: { services: ClassConstructor[] }): void; + on(item: EventItem): void; + emit(event: T, ...args: ArgsOf): Promise; /** * Send to connected clients for a specific user */ - clientSend(event: E, room: string, data: ClientEventMap[E]): void; + clientSend(event: E, room: string, ...data: ClientEventMap[E]): void; /** * Send to all connected clients */ - clientBroadcast(event: E, data: ClientEventMap[E]): void; + clientBroadcast(event: E, ...data: ClientEventMap[E]): void; /** - * Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent` + * Send to all connected servers */ - serverSend(event: E, data: ServerEventMap[E]): boolean; + serverSend(event: T, ...args: ArgsOf): void; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index bc780398eaf05..aa3090675e3fe 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -37,9 +37,7 @@ export enum JobName { // thumbnails QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_PREVIEW = 'generate-preview', - GENERATE_THUMBNAIL = 'generate-thumbnail', - GENERATE_THUMBHASH = 'generate-thumbhash', + GENERATE_THUMBNAILS = 'generate-thumbnails', GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', // metadata @@ -60,6 +58,9 @@ export enum JobName { STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + // tags + TAG_CLEANUP = 'tag-cleanup', + // migration QUEUE_MIGRATION = 'queue-migration', MIGRATE_ASSET = 'migrate-asset', @@ -73,12 +74,12 @@ export enum JobName { FACIAL_RECOGNITION = 'facial-recognition', // library management - LIBRARY_SCAN = 'library-refresh', - LIBRARY_SCAN_ASSET = 'library-refresh-asset', - LIBRARY_REMOVE_OFFLINE = 'library-remove-offline', - LIBRARY_CHECK_OFFLINE = 'library-check-offline', + LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', + LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', + LIBRARY_SYNC_FILE = 'library-sync-file', + LIBRARY_SYNC_ASSET = 'library-sync-asset', LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SCAN_ALL = 'library-queue-all-refresh', + LIBRARY_QUEUE_SYNC_ALL = 'library-queue-sync-all', LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', // cleanup @@ -90,6 +91,8 @@ export enum JobName { QUEUE_SMART_SEARCH = 'queue-smart-search', SMART_SEARCH = 'smart-search', + QUEUE_TRASH_EMPTY = 'queue-trash-empty', + // duplicate detection QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection', DUPLICATE_DETECTION = 'duplicate-detection', @@ -111,7 +114,7 @@ export enum JobName { } export const JOBS_ASSET_PAGINATION_SIZE = 1000; -export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; export interface IBaseJob { force?: boolean; @@ -120,6 +123,7 @@ export interface IBaseJob { export interface IEntityJob extends IBaseJob { id: string; source?: 'upload' | 'sidecar-write' | 'copy'; + notify?: boolean; } export interface IAssetDeleteJob extends IEntityJob { @@ -131,16 +135,11 @@ export interface ILibraryFileJob extends IEntityJob { assetPath: string; } -export interface ILibraryOfflineJob extends IEntityJob { +export interface ILibraryAssetJob extends IEntityJob { importPaths: string[]; exclusionPatterns: string[]; } -export interface ILibraryRefreshJob extends IEntityJob { - refreshModifiedFiles: boolean; - refreshAllFiles: boolean; -} - export interface IBulkEntityJob extends IBaseJob { ids: string[]; } @@ -211,9 +210,7 @@ export type JobItem = // Thumbnails | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_PREVIEW; data: IEntityJob } - | { name: JobName.GENERATE_THUMBNAIL; data: IEntityJob } - | { name: JobName.GENERATE_THUMBHASH; data: IEntityJob } + | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } // User | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } @@ -249,6 +246,7 @@ export type JobItem = // Smart Search | { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob } | { name: JobName.SMART_SEARCH; data: IEntityJob } + | { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob } // Duplicate Detection | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } @@ -261,18 +259,21 @@ export type JobItem = | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + // Tags + | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } // Library Management - | { name: JobName.LIBRARY_SCAN_ASSET; data: ILibraryFileJob } - | { name: JobName.LIBRARY_SCAN; data: ILibraryRefreshJob } - | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } + | { name: JobName.LIBRARY_SYNC_ASSET; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } - | { name: JobName.LIBRARY_CHECK_OFFLINE; data: IEntityJob } + | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } // Notification @@ -299,7 +300,6 @@ export interface IJobRepository { addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void; addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void; updateCronJob(name: string, expression?: string, start?: boolean): void; - deleteCronJob(name: string): void; setConcurrency(queueName: QueueName, concurrency: number): void; queue(item: JobItem): Promise; queueAll(items: JobItem[]): Promise; diff --git a/server/src/interfaces/logger.interface.ts b/server/src/interfaces/logger.interface.ts index f0afdce2a521c..92984bf8e146a 100644 --- a/server/src/interfaces/logger.interface.ts +++ b/server/src/interfaces/logger.interface.ts @@ -1,11 +1,12 @@ -import { LogLevel } from 'src/config'; +import { ImmichWorker, LogLevel } from 'src/enum'; export const ILoggerRepository = 'ILoggerRepository'; export interface ILoggerRepository { - setAppName(name: string): void; + setAppName(name: ImmichWorker): void; setContext(message: string): void; - setLogLevel(level: LogLevel): void; + setLogLevel(level: LogLevel | false): void; + isLevelEnabled(level: LogLevel): boolean; verbose(message: any, ...args: any): void; debug(message: any, ...args: any): void; diff --git a/server/src/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts index dce75ffd25b03..80b37c3a5f182 100644 --- a/server/src/interfaces/map.interface.ts +++ b/server/src/interfaces/map.interface.ts @@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult { export interface IMapRepository { init(): Promise; - reverseGeocode(point: GeoPoint): Promise; + reverseGeocode(point: GeoPoint): Promise; getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; fetchStyle(url: string): Promise; } diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index f7389d3d068cd..2bc8ccde36d8b 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,5 +1,5 @@ import { Writable } from 'node:stream'; -import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/config'; +import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum'; export const IMediaRepository = 'IMediaRepository'; @@ -10,13 +10,44 @@ export interface CropOptions { height: number; } -export interface ThumbnailOptions { - size: number; +export interface ImageOptions { format: ImageFormat; - colorspace: string; quality: number; + size: number; +} + +export interface RawImageInfo { + width: number; + height: number; + channels: 1 | 2 | 3 | 4; +} + +interface DecodeImageOptions { + colorspace: string; + crop?: CropOptions; + processInvalidImages: boolean; + raw?: RawImageInfo; +} + +export interface DecodeToBufferOptions extends DecodeImageOptions { + size: number; +} + +export type GenerateThumbnailOptions = ImageOptions & DecodeImageOptions; + +export type GenerateThumbnailFromBufferOptions = GenerateThumbnailOptions & { raw: RawImageInfo }; + +export type GenerateThumbhashOptions = DecodeImageOptions; + +export type GenerateThumbhashFromBufferOptions = GenerateThumbhashOptions & { raw: RawImageInfo }; + +export interface GenerateThumbnailsOptions { + colorspace: string; crop?: CropOptions; + preview?: ImageOptions; processInvalidImages: boolean; + thumbhash?: boolean; + thumbnail?: ImageOptions; } export interface VideoStreamInfo { @@ -62,6 +93,10 @@ export interface TranscodeCommand { inputOptions: string[]; outputOptions: string[]; twoPass: boolean; + progress: { + frameCount: number; + percentInterval: number; + }; } export interface BitrateDistribution { @@ -71,6 +106,11 @@ export interface BitrateDistribution { unit: string; } +export interface ImageBuffer { + data: Buffer; + info: RawImageInfo; +} + export interface VideoCodecSWConfig { getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand; } @@ -79,14 +119,21 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { getSupportedCodecs(): Array; } +export interface ProbeOptions { + countFrames: boolean; +} + export interface IMediaRepository { // image extract(input: string, output: string): Promise; - generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise; - generateThumbhash(imagePath: string): Promise; + decodeImage(input: string, options: DecodeToBufferOptions): Promise; + generateThumbnail(input: string, options: GenerateThumbnailOptions, outputFile: string): Promise; + generateThumbnail(input: Buffer, options: GenerateThumbnailFromBufferOptions, outputFile: string): Promise; + generateThumbhash(input: string, options: GenerateThumbhashOptions): Promise; + generateThumbhash(input: Buffer, options: GenerateThumbhashFromBufferOptions): Promise; getImageDimensions(input: string): Promise; // video - probe(input: string): Promise; + probe(input: string, options?: ProbeOptions): Promise; transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; } diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 04e7b89d1e138..574420e27a1c8 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -7,7 +7,18 @@ export interface ExifDuration { Scale?: number; } -type TagsWithWrongTypes = 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo'; +type StringOrNumber = string | number; + +type TagsWithWrongTypes = + | 'FocalLength' + | 'Duration' + | 'Description' + | 'ImageDescription' + | 'RegionInfo' + | 'TagsList' + | 'Keywords' + | 'HierarchicalSubject' + | 'ISO'; export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; @@ -20,10 +31,14 @@ export interface ImmichTags extends Omit { EmbeddedVideoType?: string; EmbeddedVideoFile?: BinaryField; MotionPhotoVideo?: BinaryField; + TagsList?: StringOrNumber[]; + HierarchicalSubject?: StringOrNumber[]; + Keywords?: StringOrNumber | StringOrNumber[]; + ISO?: number | number[]; // Type is wrong, can also be number. - Description?: string | number; - ImageDescription?: string | number; + Description?: StringOrNumber; + ImageDescription?: StringOrNumber; // Extended properties for image regions, such as faces RegionInfo?: { @@ -50,12 +65,7 @@ export interface ImmichTags extends Omit { export interface IMetadataRepository { teardown(): Promise; - readTags(path: string): Promise; + readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; - getCountries(userIds: string[]): Promise>; - getStates(userIds: string[], country?: string): Promise>; - getCities(userIds: string[], country?: string, state?: string): Promise>; - getCameraMakes(userIds: string[], model?: string): Promise>; - getCameraModels(userIds: string[], make?: string): Promise>; } diff --git a/server/src/interfaces/move.interface.ts b/server/src/interfaces/move.interface.ts index c9d39e78cf497..0e79cfcadc5a8 100644 --- a/server/src/interfaces/move.interface.ts +++ b/server/src/interfaces/move.interface.ts @@ -1,4 +1,5 @@ -import { MoveEntity, PathType } from 'src/entities/move.entity'; +import { MoveEntity } from 'src/entities/move.entity'; +import { PathType } from 'src/enum'; export const IMoveRepository = 'IMoveRepository'; diff --git a/server/src/interfaces/oauth.interface.ts b/server/src/interfaces/oauth.interface.ts new file mode 100644 index 0000000000000..5e629726a0a76 --- /dev/null +++ b/server/src/interfaces/oauth.interface.ts @@ -0,0 +1,22 @@ +import { UserinfoResponse } from 'openid-client'; + +export const IOAuthRepository = 'IOAuthRepository'; + +export type OAuthConfig = { + clientId: string; + clientSecret: string; + issuerUrl: string; + mobileOverrideEnabled: boolean; + mobileRedirectUri: string; + profileSigningAlgorithm: string; + scope: string; + signingAlgorithm: string; +}; +export type OAuthProfile = UserinfoResponse; + +export interface IOAuthRepository { + init(): void; + authorize(config: OAuthConfig, redirectUrl: string): Promise; + getLogoutEndpoint(config: OAuthConfig): Promise; + getProfile(config: OAuthConfig, url: string, redirectUrl: string): Promise; +} diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index fc6a389f3cc06..57e46a439b308 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,6 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { AssetEntity } from 'src/entities/asset.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; +import { SourceType } from 'src/enum'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -40,10 +41,12 @@ export interface PeopleStatistics { hidden: number; } -export interface DeleteAllFacesOptions { - sourceType?: string; +export interface DeleteFacesOptions { + sourceType: SourceType; } +export type UnassignFacesOptions = DeleteFacesOptions; + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; @@ -52,14 +55,17 @@ export interface IPersonRepository { getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; - getAssets(personId: string): Promise; - - create(entities: Partial[]): Promise; + create(person: Partial): Promise; + createAll(people: Partial[]): Promise; createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; deleteAll(): Promise; - deleteAllFaces(options: DeleteAllFacesOptions): Promise; - replaceFaces(assetId: string, entities: Partial[], sourceType?: string): Promise; + deleteFaces(options: DeleteFacesOptions): Promise; + refreshFaces( + facesToAdd: Partial[], + faceIdsToRemove: string[], + embeddingsToAdd?: FaceSearchEntity[], + ): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; getFaceByIdWithAssets( @@ -74,6 +80,8 @@ export interface IPersonRepository { reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; - update(entities: Partial[]): Promise; + unassignFaces(options: UnassignFacesOptions): Promise; + update(person: Partial): Promise; + updateAll(people: Partial[]): Promise; getLatestFaceDate(): Promise; } diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 0226e3663c3b5..63d74a35fb626 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -1,7 +1,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { AssetType } from 'src/enum'; +import { AssetStatus, AssetType } from 'src/enum'; import { Paginated } from 'src/utils/pagination'; export const ISearchRepository = 'ISearchRepository'; @@ -61,6 +61,7 @@ export interface SearchStatusOptions { isVisible?: boolean; isNotInAlbum?: boolean; type?: AssetType; + status?: AssetStatus; withArchived?: boolean; withDeleted?: boolean; } @@ -175,10 +176,16 @@ export interface ISearchRepository { searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; searchDuplicates(options: AssetDuplicateSearch): Promise; searchFaces(search: FaceEmbeddingSearch): Promise; + searchRandom(size: number, options: AssetSearchOptions): Promise; upsert(assetId: string, embedding: number[]): Promise; searchPlaces(placeName: string): Promise; getAssetsByCity(userIds: string[]): Promise; deleteAllSearchEmbeddings(): Promise; getDimensionSize(): Promise; setDimensionSize(dimSize: number): Promise; + getCountries(userIds: string[]): Promise>; + getStates(userIds: string[], country?: string): Promise>; + getCities(userIds: string[], country?: string, state?: string): Promise>; + getCameraMakes(userIds: string[], model?: string): Promise>; + getCameraModels(userIds: string[], make?: string): Promise>; } diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index fec3d66dd5c03..321f7b8367f23 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -35,7 +35,9 @@ export interface IStorageRepository { createZipStream(): ImmichZipStream; createReadStream(filepath: string, mimeType?: string | null): Promise; readFile(filepath: string, options?: FileReadOptions): Promise; - writeFile(filepath: string, buffer: Buffer): Promise; + createFile(filepath: string, buffer: Buffer): Promise; + createOrOverwriteFile(filepath: string, buffer: Buffer): Promise; + overwriteFile(filepath: string, buffer: Buffer): Promise; realpath(filepath: string): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index aca9c223d552b..16a34d6ac4960 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset { upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; upsertAssetIds(items: AssetTagItem[]): Promise; + deleteEmptyTags(): Promise; } diff --git a/server/src/interfaces/trash.interface.ts b/server/src/interfaces/trash.interface.ts new file mode 100644 index 0000000000000..96c2322d8afd6 --- /dev/null +++ b/server/src/interfaces/trash.interface.ts @@ -0,0 +1,10 @@ +import { Paginated, PaginationOptions } from 'src/utils/pagination'; + +export const ITrashRepository = 'ITrashRepository'; + +export interface ITrashRepository { + empty(userId: string): Promise; + restore(userId: string): Promise; + restoreAll(assetIds: string[]): Promise; + getDeletedIds(pagination: PaginationOptions): Paginated; +} diff --git a/server/src/interfaces/version-history.interface.ts b/server/src/interfaces/version-history.interface.ts new file mode 100644 index 0000000000000..67337062200f0 --- /dev/null +++ b/server/src/interfaces/version-history.interface.ts @@ -0,0 +1,9 @@ +import { VersionHistoryEntity } from 'src/entities/version-history.entity'; + +export const IVersionHistoryRepository = 'IVersionHistoryRepository'; + +export interface IVersionHistoryRepository { + create(version: Omit): Promise; + getAll(): Promise; + getLatest(): Promise; +} diff --git a/server/src/interfaces/view.interface.ts b/server/src/interfaces/view.interface.ts new file mode 100644 index 0000000000000..f819160002041 --- /dev/null +++ b/server/src/interfaces/view.interface.ts @@ -0,0 +1,8 @@ +import { AssetEntity } from 'src/entities/asset.entity'; + +export const IViewRepository = 'IViewRepository'; + +export interface IViewRepository { + getAssetsByOriginalPath(userId: string, partialPath: string): Promise; + getUniqueOriginalPaths(userId: string): Promise; +} diff --git a/server/src/main.ts b/server/src/main.ts index ee4de1a259d19..11cc44ec10b92 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -2,23 +2,20 @@ import { CommandFactory } from 'nest-commander'; import { fork } from 'node:child_process'; import { Worker } from 'node:worker_threads'; import { ImmichAdminModule } from 'src/app.module'; -import { LogLevel } from 'src/config'; -import { getWorkers } from 'src/utils/workers'; -const immichApp = process.argv[2] || process.env.IMMICH_APP; +import { ImmichWorker, LogLevel } from 'src/enum'; +import { ConfigRepository } from 'src/repositories/config.repository'; -if (process.argv[2] === immichApp) { +const immichApp = process.argv[2]; +if (immichApp) { process.argv.splice(2, 1); } -async function bootstrapImmichAdmin() { - process.env.IMMICH_LOG_LEVEL = LogLevel.WARN; - await CommandFactory.run(ImmichAdminModule); -} - -function bootstrapWorker(name: string) { +function bootstrapWorker(name: ImmichWorker) { console.log(`Starting ${name} worker`); - const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`); + const execArgv = process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)); + const worker = + name === 'api' ? fork(`./dist/workers/${name}.js`, [], { execArgv }) : new Worker(`./dist/workers/${name}.js`); worker.on('error', (error) => { console.error(`${name} worker error: ${error}`); @@ -33,26 +30,27 @@ function bootstrapWorker(name: string) { } function bootstrap() { - switch (immichApp) { - case 'immich-admin': { - process.title = 'immich_admin_cli'; - return bootstrapImmichAdmin(); - } - case 'immich': { - if (!process.env.IMMICH_WORKERS_INCLUDE) { - process.env.IMMICH_WORKERS_INCLUDE = 'api'; - } - break; - } - case 'microservices': { - if (!process.env.IMMICH_WORKERS_INCLUDE) { - process.env.IMMICH_WORKERS_INCLUDE = 'microservices'; - } - break; - } + if (immichApp === 'immich-admin') { + process.title = 'immich_admin_cli'; + process.env.IMMICH_LOG_LEVEL = LogLevel.WARN; + return CommandFactory.run(ImmichAdminModule); } + + if (immichApp === 'immich' || immichApp === 'microservices') { + console.error( + `Using "start.sh ${immichApp}" has been deprecated. See https://github.com/immich-app/immich/releases/tag/v1.118.0 for more information.`, + ); + process.exit(1); + } + + if (immichApp) { + console.error(`Unknown command: "${immichApp}"`); + process.exit(1); + } + process.title = 'immich'; - for (const worker of getWorkers()) { + const { workers } = new ConfigRepository().getEnv(); + for (const worker of workers) { bootstrapWorker(worker); } } diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index d6138f2d3ae24..7bc4f41b21c8f 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -11,19 +11,11 @@ import { Reflector } from '@nestjs/core'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { Request } from 'express'; import { AuthDto, ImmichQuery } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { MetadataKey, Permission } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { UAParser } from 'ua-parser-js'; -export enum Metadata { - AUTH_ROUTE = 'auth_route', - ADMIN_ROUTE = 'admin_route', - SHARED_ROUTE = 'shared_route', - API_KEY_SECURITY = 'api_key', - ON_EMIT_CONFIG = 'on_emit_config', -} - type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); @@ -32,8 +24,8 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator = const decorators: MethodDecorator[] = [ ApiBearerAuth(), ApiCookieAuth(), - ApiSecurity(Metadata.API_KEY_SECURITY), - SetMetadata(Metadata.AUTH_ROUTE, options || {}), + ApiSecurity(MetadataKey.API_KEY_SECURITY), + SetMetadata(MetadataKey.AUTH_ROUTE, options || {}), ]; if ((options as SharedLinkRoute)?.sharedLink) { @@ -85,7 +77,7 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const targets = [context.getHandler()]; - const options = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets); + const options = this.reflector.getAllAndOverride(MetadataKey.AUTH_ROUTE, targets); if (!options) { return true; } diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6ec8b401efb7f..075a7f504636a 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -7,6 +7,7 @@ import multer, { StorageEngine, diskStorage } from 'multer'; import { createHash, randomUUID } from 'node:crypto'; import { Observable } from 'rxjs'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; @@ -28,11 +29,6 @@ export function getFiles(files: UploadFiles) { }; } -export enum Route { - ASSET = 'assets', - USER = 'users', -} - export interface ImmichFile extends Express.Multer.File { /** sha1 hash of file */ uuid: string; @@ -115,7 +111,7 @@ export class FileUploadInterceptor implements NestInterceptor { const context_ = context.switchToHttp(); const route = this.reflect.get(PATH_METADATA, context.getClass()); - const handler: RequestHandler | null = this.getHandler(route as Route); + const handler: RequestHandler | null = this.getHandler(route as RouteKey); if (handler) { await new Promise((resolve, reject) => { const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve()); @@ -176,13 +172,13 @@ export class FileUploadInterceptor implements NestInterceptor { return false; } - private getHandler(route: Route) { + private getHandler(route: RouteKey) { switch (route) { - case Route.ASSET: { + case RouteKey.ASSET: { return this.handlers.assetUpload; } - case Route.USER: { + case RouteKey.USER: { return this.handlers.userProfile; } diff --git a/server/src/migrations/1700713871511-UsePgVectors.ts b/server/src/migrations/1700713871511-UsePgVectors.ts index 033e2ba9ad990..e67c7275a796d 100644 --- a/server/src/migrations/1700713871511-UsePgVectors.ts +++ b/server/src/migrations/1700713871511-UsePgVectors.ts @@ -1,13 +1,15 @@ -import { getVectorExtension } from 'src/database.config'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { getCLIPModelInfo } from 'src/utils/misc'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class UsePgVectors1700713871511 implements MigrationInterface { name = 'UsePgVectors1700713871511'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`SET search_path TO "$user", public, vectors`); - await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${getVectorExtension()}`); + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS ${vectorExtension}`); const faceDimQuery = await queryRunner.query(` SELECT CARDINALITY(embedding::real[]) as dimsize FROM asset_faces diff --git a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts index e325f270fd36e..f9ea5a0dc3110 100644 --- a/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts +++ b/server/src/migrations/1700713994428-AddCLIPEmbeddingIndex.ts @@ -1,12 +1,14 @@ -import { getVectorExtension } from 'src/database.config'; import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface { name = 'AddCLIPEmbeddingIndex1700713994428'; public async up(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } await queryRunner.query(`SET search_path TO "$user", public, vectors`); diff --git a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts index bc6bad6dbdd87..d11e7b921e8f5 100644 --- a/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts +++ b/server/src/migrations/1700714033632-AddFaceEmbeddingIndex.ts @@ -1,12 +1,14 @@ -import { getVectorExtension } from 'src/database.config'; import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface { name = 'AddFaceEmbeddingIndex1700714033632'; public async up(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } await queryRunner.query(`SET search_path TO "$user", public, vectors`); diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts index c8e02ec0c5e5a..ae6d752c65da9 100644 --- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -1,10 +1,12 @@ -import { getVectorExtension } from 'src/database.config'; import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { MigrationInterface, QueryRunner } from 'typeorm'; +const vectorExtension = new ConfigRepository().getEnv().database.vectorExtension; + export class AddFaceSearchRelation1718486162779 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } @@ -13,9 +15,10 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { const columns = await queryRunner.query( `SELECT column_name as name FROM information_schema.columns - WHERE table_name = '${tableName}'`); + WHERE table_name = '${tableName}'`, + ); return columns.some((column: { name: string }) => column.name === 'embedding'); - } + }; const hasAssetEmbeddings = await hasEmbeddings('smart_search'); if (!hasAssetEmbeddings) { @@ -31,7 +34,7 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { await queryRunner.query(`ALTER TABLE face_search ALTER COLUMN embedding SET STORAGE EXTERNAL`); await queryRunner.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET STORAGE EXTERNAL`); - const hasFaceEmbeddings = await hasEmbeddings('asset_faces') + const hasFaceEmbeddings = await hasEmbeddings('asset_faces'); if (hasFaceEmbeddings) { await queryRunner.query(` INSERT INTO face_search("faceId", embedding) @@ -56,7 +59,7 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - if (getVectorExtension() === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.VECTORS) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); await queryRunner.query(`SET vectors.pgvector_compatibility=on`); } diff --git a/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts b/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts new file mode 100644 index 0000000000000..2dfb5b7978be3 --- /dev/null +++ b/server/src/migrations/1725730782681-RemoveHiddenAssetsFromAlbums.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveHiddenAssetsFromAlbums1725730782681 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "albums_assets_assets" WHERE "assetsId" IN (SELECT "id" FROM "assets" WHERE "isVisible" = false)`, + ); + } + + public async down(): Promise { + // noop + } +} diff --git a/server/src/migrations/1726491047923-AddprofileChangedAt.ts b/server/src/migrations/1726491047923-AddprofileChangedAt.ts new file mode 100644 index 0000000000000..bcf568426a817 --- /dev/null +++ b/server/src/migrations/1726491047923-AddprofileChangedAt.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddprofileChangedAt1726491047923 implements MigrationInterface { + name = 'AddprofileChangedAt1726491047923' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "profileChangedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "profileChangedAt"`); + } + +} diff --git a/server/src/migrations/1726593009549-AddAssetStatus.ts b/server/src/migrations/1726593009549-AddAssetStatus.ts new file mode 100644 index 0000000000000..5b243b05b5c24 --- /dev/null +++ b/server/src/migrations/1726593009549-AddAssetStatus.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAssetStatus1726593009549 implements MigrationInterface { + name = 'AddAssetStatus1726593009549' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "assets_status_enum" AS ENUM('active', 'trashed', 'deleted')`); + await queryRunner.query(`ALTER TABLE "assets" ADD "status" "assets_status_enum" NOT NULL DEFAULT 'active'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "status"`); + await queryRunner.query(`DROP TYPE "assets_status_enum"`); + } + +} diff --git a/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts new file mode 100644 index 0000000000000..e02203997f723 --- /dev/null +++ b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls( + jsonb_build_object( + 'preview', jsonb_build_object( + 'format', value->'image'->'previewFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'previewSize'), + 'thumbnail', jsonb_build_object( + 'format', value->'image'->'thumbnailFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'thumbnailSize'), + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace' + ))) + where key = 'system-config'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object( + 'previewFormat', value->'image'->'preview'->'format', + 'previewSize', value->'image'->'preview'->'size', + 'thumbnailFormat', value->'image'->'thumbnail'->'format', + 'thumbnailSize', value->'image'->'thumbnail'->'size', + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace', + 'quality', value->'image'->'preview'->'quality' + ))) + where key = 'system-config'`); + } +} diff --git a/server/src/migrations/1727781844613-IsOfflineSetDeletedAt.ts b/server/src/migrations/1727781844613-IsOfflineSetDeletedAt.ts new file mode 100644 index 0000000000000..050e9a93cfb87 --- /dev/null +++ b/server/src/migrations/1727781844613-IsOfflineSetDeletedAt.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class IsOfflineSetDeletedAt1727781844613 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE assets SET "deletedAt" = now() WHERE "isOffline" = true AND "deletedAt" IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE assets SET "deletedAt" = null WHERE "isOffline" = true`, + ); + } +} diff --git a/server/src/migrations/1727797340951-AddVersionHistory.ts b/server/src/migrations/1727797340951-AddVersionHistory.ts new file mode 100644 index 0000000000000..7eb731d1a3e18 --- /dev/null +++ b/server/src/migrations/1727797340951-AddVersionHistory.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddVersionHistory1727797340951 implements MigrationInterface { + name = 'AddVersionHistory1727797340951' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "version_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "version" character varying NOT NULL, CONSTRAINT "PK_5db259cbb09ce82c0d13cfd1b23" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "version_history"`); + } + +} diff --git a/server/src/queries/activity.repository.sql b/server/src/queries/activity.repository.sql index 3f3e04140ccc2..44042c0e6d39f 100644 --- a/server/src/queries/activity.repository.sql +++ b/server/src/queries/activity.repository.sql @@ -23,7 +23,8 @@ SELECT "ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status", "ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt", "ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes", - "ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes" + "ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes", + "ActivityEntity__ActivityEntity_user"."profileChangedAt" AS "ActivityEntity__ActivityEntity_user_profileChangedAt" FROM "activity" "ActivityEntity" LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId" diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index 729f7c7f20f0d..c4f6fbdd3218b 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -30,6 +30,7 @@ FROM "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", @@ -47,6 +48,7 @@ FROM "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -80,64 +82,6 @@ ORDER BY LIMIT 1 --- AlbumRepository.getByIds -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "AlbumEntity"."ownerId" AS "AlbumEntity_ownerId", - "AlbumEntity"."albumName" AS "AlbumEntity_albumName", - "AlbumEntity"."description" AS "AlbumEntity_description", - "AlbumEntity"."createdAt" AS "AlbumEntity_createdAt", - "AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt", - "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", - "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", - "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", - "AlbumEntity"."order" AS "AlbumEntity_order", - "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", - "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", - "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", - "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", - "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId", - "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath", - "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", - "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", - "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", - "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", - "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", - "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", - "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", - "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" -FROM - "albums" "AlbumEntity" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" - AND ( - "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL - ) - LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" - LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId" - AND ( - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL - ) -WHERE - ((("AlbumEntity"."id" IN ($1)))) - AND ("AlbumEntity"."deletedAt" IS NULL) - -- AlbumRepository.getByAssetId SELECT "AlbumEntity"."id" AS "AlbumEntity_id", @@ -164,6 +108,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt", "AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId", "AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId", "AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role", @@ -180,7 +125,8 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", - "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes" + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" @@ -241,35 +187,6 @@ WHERE GROUP BY "album"."id" --- AlbumRepository.getInvalidThumbnail -SELECT - "albums"."id" AS "albums_id" -FROM - "albums" "albums" -WHERE - ( - "albums"."albumThumbnailAssetId" IS NULL - AND EXISTS ( - SELECT - 1 - FROM - "albums_assets_assets" "albums_assets" - WHERE - "albums"."id" = "albums_assets"."albumsId" - ) - OR "albums"."albumThumbnailAssetId" IS NOT NULL - AND NOT EXISTS ( - SELECT - 1 - FROM - "albums_assets_assets" "albums_assets" - WHERE - "albums"."id" = "albums_assets"."albumsId" - AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId" - ) - ) - AND ("albums"."deletedAt" IS NULL) - -- AlbumRepository.getOwned SELECT "AlbumEntity"."id" AS "AlbumEntity_id", @@ -299,6 +216,7 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -324,7 +242,8 @@ SELECT "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" @@ -372,6 +291,7 @@ SELECT "a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes", "a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes", + "a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt", "AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id", "AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description", "AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password", @@ -397,7 +317,8 @@ SELECT "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" @@ -495,7 +416,8 @@ SELECT "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" + "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes", + "AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id" @@ -528,41 +450,6 @@ WHERE ORDER BY "AlbumEntity"."createdAt" DESC --- AlbumRepository.getAll -SELECT - "AlbumEntity"."id" AS "AlbumEntity_id", - "AlbumEntity"."ownerId" AS "AlbumEntity_ownerId", - "AlbumEntity"."albumName" AS "AlbumEntity_albumName", - "AlbumEntity"."description" AS "AlbumEntity_description", - "AlbumEntity"."createdAt" AS "AlbumEntity_createdAt", - "AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt", - "AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt", - "AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId", - "AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled", - "AlbumEntity"."order" AS "AlbumEntity_order", - "AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id", - "AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name", - "AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin", - "AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email", - "AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel", - "AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId", - "AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath", - "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", - "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", - "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", - "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", - "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", - "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", - "AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes" -FROM - "albums" "AlbumEntity" - LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId" - AND ( - "AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL - ) -WHERE - "AlbumEntity"."deletedAt" IS NULL - -- AlbumRepository.removeAsset DELETE FROM "albums_assets_assets" WHERE @@ -596,16 +483,13 @@ UPDATE "albums" SET "albumThumbnailAssetId" = ( SELECT - "albums_assets2"."assetsId" + "album_assets"."assetsId" FROM - "assets" "assets", - "albums_assets_assets" "albums_assets2" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - ( - "albums_assets2"."assetsId" = "assets"."id" - AND "albums_assets2"."albumsId" = "albums"."id" - ) - AND ("assets"."deletedAt" IS NULL) + "album_assets"."albumsId" = "albums"."id" ORDER BY "assets"."fileCreatedAt" DESC LIMIT @@ -618,17 +502,21 @@ WHERE SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" + "album_assets"."albumsId" = "albums"."id" ) OR "albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM - "albums_assets_assets" "albums_assets" + "albums_assets_assets" "album_assets" + INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id" + AND "assets"."deletedAt" IS NULL WHERE - "albums"."id" = "albums_assets"."albumsId" - AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId" + "album_assets"."albumsId" = "albums"."id" + AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId" ) diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index e5f389ac4d017..f4989d355e880 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -24,6 +24,7 @@ FROM "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", "APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes", "APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes", + "APIKeyEntity__APIKeyEntity_user"."profileChangedAt" AS "APIKeyEntity__APIKeyEntity_user_profileChangedAt", "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId", "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key", "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value" diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 9b4b17425c409..eda91482bb1d0 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -8,6 +8,7 @@ SELECT "entity"."libraryId" AS "entity_libraryId", "entity"."deviceId" AS "entity_deviceId", "entity"."type" AS "entity_type", + "entity"."status" AS "entity_status", "entity"."originalPath" AS "entity_originalPath", "entity"."thumbhash" AS "entity_thumbhash", "entity"."encodedVideoPath" AS "entity_encodedVideoPath", @@ -96,6 +97,7 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", @@ -130,6 +132,7 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", @@ -218,6 +221,7 @@ SELECT "bd93d5747511a4dad4923546c51365bf1a803774"."libraryId" AS "bd93d5747511a4dad4923546c51365bf1a803774_libraryId", "bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId", "bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type", + "bd93d5747511a4dad4923546c51365bf1a803774"."status" AS "bd93d5747511a4dad4923546c51365bf1a803774_status", "bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath", "bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash", "bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath", @@ -264,35 +268,6 @@ DELETE FROM "assets" WHERE "ownerId" = $1 --- AssetRepository.getExternalLibraryAssetPaths -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" -FROM - ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline" - FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - AND ( - "AssetEntity__AssetEntity_library"."deletedAt" IS NULL - ) - WHERE - ( - ( - ((("AssetEntity__AssetEntity_library"."id" = $1))) - AND ("AssetEntity"."isExternal" = $2) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "AssetEntity_id" ASC -LIMIT - 2 - -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" @@ -305,6 +280,7 @@ FROM "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", @@ -361,18 +337,6 @@ WHERE AND "originalPath" = path ); --- AssetRepository.updateOfflineLibraryAssets -UPDATE "assets" -SET - "isOffline" = $1, - "updatedAt" = CURRENT_TIMESTAMP -WHERE - ( - "libraryId" = $2 - AND NOT ("originalPath" IN ($3)) - AND "isOffline" = $4 - ) - -- AssetRepository.getAllByDeviceId SELECT "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", @@ -402,6 +366,7 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", @@ -455,6 +420,7 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", @@ -493,6 +459,7 @@ LIMIT -- AssetRepository.getByChecksums SELECT "AssetEntity"."id" AS "AssetEntity_id", + "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", "AssetEntity"."checksum" AS "AssetEntity_checksum" FROM "assets" "AssetEntity" @@ -526,6 +493,7 @@ SELECT "AssetEntity"."libraryId" AS "AssetEntity_libraryId", "AssetEntity"."deviceId" AS "AssetEntity_deviceId", "AssetEntity"."type" AS "AssetEntity_type", + "AssetEntity"."status" AS "AssetEntity_status", "AssetEntity"."originalPath" AS "AssetEntity_originalPath", "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", @@ -580,6 +548,7 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -639,6 +608,7 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", @@ -718,6 +688,7 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -777,6 +748,7 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", @@ -832,6 +804,7 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -891,6 +864,7 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", @@ -996,6 +970,7 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -1071,6 +1046,7 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -1133,110 +1109,31 @@ WHERE AND "asset"."ownerId" IN ($1) AND "asset"."updatedAt" > $2 --- AssetRepository.getAssetsByOriginalPath -SELECT - "asset"."id" AS "asset_id", - "asset"."deviceAssetId" AS "asset_deviceAssetId", - "asset"."ownerId" AS "asset_ownerId", - "asset"."libraryId" AS "asset_libraryId", - "asset"."deviceId" AS "asset_deviceId", - "asset"."type" AS "asset_type", - "asset"."originalPath" AS "asset_originalPath", - "asset"."thumbhash" AS "asset_thumbhash", - "asset"."encodedVideoPath" AS "asset_encodedVideoPath", - "asset"."createdAt" AS "asset_createdAt", - "asset"."updatedAt" AS "asset_updatedAt", - "asset"."deletedAt" AS "asset_deletedAt", - "asset"."fileCreatedAt" AS "asset_fileCreatedAt", - "asset"."localDateTime" AS "asset_localDateTime", - "asset"."fileModifiedAt" AS "asset_fileModifiedAt", - "asset"."isFavorite" AS "asset_isFavorite", - "asset"."isArchived" AS "asset_isArchived", - "asset"."isExternal" AS "asset_isExternal", - "asset"."isOffline" AS "asset_isOffline", - "asset"."checksum" AS "asset_checksum", - "asset"."duration" AS "asset_duration", - "asset"."isVisible" AS "asset_isVisible", - "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", - "asset"."originalFileName" AS "asset_originalFileName", - "asset"."sidecarPath" AS "asset_sidecarPath", - "asset"."stackId" AS "asset_stackId", - "asset"."duplicateId" AS "asset_duplicateId", - "exifInfo"."assetId" AS "exifInfo_assetId", - "exifInfo"."description" AS "exifInfo_description", - "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", - "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", - "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", - "exifInfo"."orientation" AS "exifInfo_orientation", - "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", - "exifInfo"."modifyDate" AS "exifInfo_modifyDate", - "exifInfo"."timeZone" AS "exifInfo_timeZone", - "exifInfo"."latitude" AS "exifInfo_latitude", - "exifInfo"."longitude" AS "exifInfo_longitude", - "exifInfo"."projectionType" AS "exifInfo_projectionType", - "exifInfo"."city" AS "exifInfo_city", - "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", - "exifInfo"."autoStackId" AS "exifInfo_autoStackId", - "exifInfo"."state" AS "exifInfo_state", - "exifInfo"."country" AS "exifInfo_country", - "exifInfo"."make" AS "exifInfo_make", - "exifInfo"."model" AS "exifInfo_model", - "exifInfo"."lensModel" AS "exifInfo_lensModel", - "exifInfo"."fNumber" AS "exifInfo_fNumber", - "exifInfo"."focalLength" AS "exifInfo_focalLength", - "exifInfo"."iso" AS "exifInfo_iso", - "exifInfo"."exposureTime" AS "exifInfo_exposureTime", - "exifInfo"."profileDescription" AS "exifInfo_profileDescription", - "exifInfo"."colorspace" AS "exifInfo_colorspace", - "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", - "exifInfo"."rating" AS "exifInfo_rating", - "exifInfo"."fps" AS "exifInfo_fps", - "stack"."id" AS "stack_id", - "stack"."ownerId" AS "stack_ownerId", - "stack"."primaryAssetId" AS "stack_primaryAssetId", - "stackedAssets"."id" AS "stackedAssets_id", - "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", - "stackedAssets"."ownerId" AS "stackedAssets_ownerId", - "stackedAssets"."libraryId" AS "stackedAssets_libraryId", - "stackedAssets"."deviceId" AS "stackedAssets_deviceId", - "stackedAssets"."type" AS "stackedAssets_type", - "stackedAssets"."originalPath" AS "stackedAssets_originalPath", - "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", - "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", - "stackedAssets"."createdAt" AS "stackedAssets_createdAt", - "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", - "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", - "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", - "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", - "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", - "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", - "stackedAssets"."isArchived" AS "stackedAssets_isArchived", - "stackedAssets"."isExternal" AS "stackedAssets_isExternal", - "stackedAssets"."isOffline" AS "stackedAssets_isOffline", - "stackedAssets"."checksum" AS "stackedAssets_checksum", - "stackedAssets"."duration" AS "stackedAssets_duration", - "stackedAssets"."isVisible" AS "stackedAssets_isVisible", - "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", - "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", - "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", - "stackedAssets"."stackId" AS "stackedAssets_stackId", - "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" -FROM - "assets" "asset" - LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" - LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" - LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" - AND ("stackedAssets"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND ( - "asset"."originalPath" LIKE $2 - AND "asset"."originalPath" NOT LIKE $3 +-- AssetRepository.upsertFile +INSERT INTO + "asset_files" ( + "id", + "assetId", + "createdAt", + "updatedAt", + "type", + "path" ) -ORDER BY - regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC +VALUES + (DEFAULT, $1, DEFAULT, DEFAULT, $2, $3) +ON CONFLICT ("assetId", "type") DO +UPDATE +SET + "assetId" = EXCLUDED."assetId", + "type" = EXCLUDED."type", + "path" = EXCLUDED."path", + "updatedAt" = DEFAULT +RETURNING + "id", + "createdAt", + "updatedAt" --- AssetRepository.upsertFile +-- AssetRepository.upsertFiles INSERT INTO "asset_files" ( "id", diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 5dd32ce365d9e..a5d6ba05dba1f 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -28,7 +28,8 @@ FROM "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" + "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", + "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" FROM "libraries" "LibraryEntity" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" @@ -68,7 +69,8 @@ SELECT "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" + "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", + "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" FROM "libraries" "LibraryEntity" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" @@ -104,7 +106,8 @@ SELECT "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", - "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes" + "LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes", + "LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt" FROM "libraries" "LibraryEntity" LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId" diff --git a/server/src/queries/metadata.repository.sql b/server/src/queries/metadata.repository.sql deleted file mode 100644 index 077b4644b824d..0000000000000 --- a/server/src/queries/metadata.repository.sql +++ /dev/null @@ -1,56 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- MetadataRepository.getCountries -SELECT DISTINCT - ON ("exif"."country") "exif"."country" AS "country" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - --- MetadataRepository.getStates -SELECT DISTINCT - ON ("exif"."state") "exif"."state" AS "state" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."country" = $2 - --- MetadataRepository.getCities -SELECT DISTINCT - ON ("exif"."city") "exif"."city" AS "city" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."country" = $2 - AND "exif"."state" = $3 - --- MetadataRepository.getCameraMakes -SELECT DISTINCT - ON ("exif"."make") "exif"."make" AS "make" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."model" = $2 - --- MetadataRepository.getCameraModels -SELECT DISTINCT - ON ("exif"."model") "exif"."model" AS "model" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" = $1 - AND "exif"."make" = $2 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 57969e4989c91..5616559d7d06d 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -159,6 +159,7 @@ FROM "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", + "AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", @@ -215,19 +216,14 @@ SELECT "person"."isHidden" AS "person_isHidden" FROM "person" "person" - LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" WHERE "person"."ownerId" = $1 AND ( LOWER("person"."name") LIKE $2 OR LOWER("person"."name") LIKE $3 ) -GROUP BY - "person"."id" -ORDER BY - COUNT("face"."assetId") DESC LIMIT - 20 + 1000 -- PersonRepository.getDistinctNames SELECT DISTINCT @@ -252,113 +248,6 @@ WHERE AND "asset"."deletedAt" IS NULL AND "asset"."livePhotoVideoId" IS NULL --- PersonRepository.getAssets -SELECT DISTINCT - "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id", - "distinctAlias"."AssetEntity_fileCreatedAt" -FROM - ( - SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", - "AssetEntity"."ownerId" AS "AssetEntity_ownerId", - "AssetEntity"."libraryId" AS "AssetEntity_libraryId", - "AssetEntity"."deviceId" AS "AssetEntity_deviceId", - "AssetEntity"."type" AS "AssetEntity_type", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", - "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", - "AssetEntity"."createdAt" AS "AssetEntity_createdAt", - "AssetEntity"."updatedAt" AS "AssetEntity_updatedAt", - "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", - "AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt", - "AssetEntity"."localDateTime" AS "AssetEntity_localDateTime", - "AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt", - "AssetEntity"."isFavorite" AS "AssetEntity_isFavorite", - "AssetEntity"."isArchived" AS "AssetEntity_isArchived", - "AssetEntity"."isExternal" AS "AssetEntity_isExternal", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline", - "AssetEntity"."checksum" AS "AssetEntity_checksum", - "AssetEntity"."duration" AS "AssetEntity_duration", - "AssetEntity"."isVisible" AS "AssetEntity_isVisible", - "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", - "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", - "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId", - "AssetEntity"."duplicateId" AS "AssetEntity_duplicateId", - "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", - "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", - "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", - "AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth", - "AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight", - "AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1", - "AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1", - "AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2", - "AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2", - "AssetEntity__AssetEntity_faces"."sourceType" AS "AssetEntity__AssetEntity_faces_sourceType", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."ownerId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_ownerId", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."name" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_name", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."birthDate" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_birthDate", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId", - "8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden", - "AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId", - "AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description", - "AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth", - "AssetEntity__AssetEntity_exifInfo"."exifImageHeight" AS "AssetEntity__AssetEntity_exifInfo_exifImageHeight", - "AssetEntity__AssetEntity_exifInfo"."fileSizeInByte" AS "AssetEntity__AssetEntity_exifInfo_fileSizeInByte", - "AssetEntity__AssetEntity_exifInfo"."orientation" AS "AssetEntity__AssetEntity_exifInfo_orientation", - "AssetEntity__AssetEntity_exifInfo"."dateTimeOriginal" AS "AssetEntity__AssetEntity_exifInfo_dateTimeOriginal", - "AssetEntity__AssetEntity_exifInfo"."modifyDate" AS "AssetEntity__AssetEntity_exifInfo_modifyDate", - "AssetEntity__AssetEntity_exifInfo"."timeZone" AS "AssetEntity__AssetEntity_exifInfo_timeZone", - "AssetEntity__AssetEntity_exifInfo"."latitude" AS "AssetEntity__AssetEntity_exifInfo_latitude", - "AssetEntity__AssetEntity_exifInfo"."longitude" AS "AssetEntity__AssetEntity_exifInfo_longitude", - "AssetEntity__AssetEntity_exifInfo"."projectionType" AS "AssetEntity__AssetEntity_exifInfo_projectionType", - "AssetEntity__AssetEntity_exifInfo"."city" AS "AssetEntity__AssetEntity_exifInfo_city", - "AssetEntity__AssetEntity_exifInfo"."livePhotoCID" AS "AssetEntity__AssetEntity_exifInfo_livePhotoCID", - "AssetEntity__AssetEntity_exifInfo"."autoStackId" AS "AssetEntity__AssetEntity_exifInfo_autoStackId", - "AssetEntity__AssetEntity_exifInfo"."state" AS "AssetEntity__AssetEntity_exifInfo_state", - "AssetEntity__AssetEntity_exifInfo"."country" AS "AssetEntity__AssetEntity_exifInfo_country", - "AssetEntity__AssetEntity_exifInfo"."make" AS "AssetEntity__AssetEntity_exifInfo_make", - "AssetEntity__AssetEntity_exifInfo"."model" AS "AssetEntity__AssetEntity_exifInfo_model", - "AssetEntity__AssetEntity_exifInfo"."lensModel" AS "AssetEntity__AssetEntity_exifInfo_lensModel", - "AssetEntity__AssetEntity_exifInfo"."fNumber" AS "AssetEntity__AssetEntity_exifInfo_fNumber", - "AssetEntity__AssetEntity_exifInfo"."focalLength" AS "AssetEntity__AssetEntity_exifInfo_focalLength", - "AssetEntity__AssetEntity_exifInfo"."iso" AS "AssetEntity__AssetEntity_exifInfo_iso", - "AssetEntity__AssetEntity_exifInfo"."exposureTime" AS "AssetEntity__AssetEntity_exifInfo_exposureTime", - "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", - "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", - "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", - "AssetEntity__AssetEntity_exifInfo"."rating" AS "AssetEntity__AssetEntity_exifInfo_rating", - "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" - FROM - "assets" "AssetEntity" - LEFT JOIN "asset_faces" "AssetEntity__AssetEntity_faces" ON "AssetEntity__AssetEntity_faces"."assetId" = "AssetEntity"."id" - LEFT JOIN "person" "8258e303a73a72cf6abb13d73fb592dde0d68280" ON "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" = "AssetEntity__AssetEntity_faces"."personId" - LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" - WHERE - ( - ( - ( - ( - ("AssetEntity__AssetEntity_faces"."personId" = $1) - ) - ) - AND ("AssetEntity"."isVisible" = $2) - AND ("AssetEntity"."isArchived" = $3) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - ) "distinctAlias" -ORDER BY - "distinctAlias"."AssetEntity_fileCreatedAt" DESC, - "AssetEntity_id" ASC -LIMIT - 1000 - -- PersonRepository.getNumberOfPeople SELECT COUNT(DISTINCT ("person"."id")) AS "total", @@ -396,6 +285,7 @@ SELECT "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", + "AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status", "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index dd2e3ae75c819..cd9a84b016d0f 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -13,6 +13,7 @@ FROM "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -43,6 +44,7 @@ FROM "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", @@ -75,10 +77,11 @@ FROM "asset"."fileCreatedAt" >= $1 AND "exifInfo"."lensModel" = $2 AND 1 = 1 + AND "asset"."ownerId" IN ($3) AND 1 = 1 AND ( - "asset"."isFavorite" = $3 - AND "asset"."isArchived" = $4 + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 ) ) AND ("asset"."deletedAt" IS NULL) @@ -89,6 +92,190 @@ ORDER BY LIMIT 101 +-- SearchRepository.searchRandom +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" > $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "stack"."id" AS "stack_id", + "stack"."ownerId" AS "stack_ownerId", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId", + "stackedAssets"."duplicateId" AS "stackedAssets_duplicateId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND "asset"."ownerId" IN ($3) + AND 1 = 1 + AND ( + "asset"."isFavorite" = $4 + AND "asset"."isArchived" = $5 + ) + AND "asset"."id" < $6 + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "distinctAlias"."asset_id" ASC, + "asset_id" ASC +LIMIT + 100 + -- SearchRepository.searchSmart START TRANSACTION SET @@ -106,6 +293,7 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -136,6 +324,7 @@ SELECT "stackedAssets"."libraryId" AS "stackedAssets_libraryId", "stackedAssets"."deviceId" AS "stackedAssets_deviceId", "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."status" AS "stackedAssets_status", "stackedAssets"."originalPath" AS "stackedAssets_originalPath", "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", @@ -345,6 +534,7 @@ SELECT "asset"."libraryId" AS "asset_libraryId", "asset"."deviceId" AS "asset_deviceId", "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", "asset"."originalPath" AS "asset_originalPath", "asset"."thumbhash" AS "asset_thumbhash", "asset"."encodedVideoPath" AS "asset_encodedVideoPath", @@ -401,3 +591,58 @@ FROM INNER JOIN cte ON asset.id = cte."assetId" ORDER BY exif.city + +-- SearchRepository.getCountries +SELECT DISTINCT + ON ("exif"."country") "exif"."country" AS "country" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + +-- SearchRepository.getStates +SELECT DISTINCT + ON ("exif"."state") "exif"."state" AS "state" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."country" = $2 + +-- SearchRepository.getCities +SELECT DISTINCT + ON ("exif"."city") "exif"."city" AS "city" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."country" = $2 + AND "exif"."state" = $3 + +-- SearchRepository.getCameraMakes +SELECT DISTINCT + ON ("exif"."make") "exif"."make" AS "make" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."model" = $2 + +-- SearchRepository.getCameraModels +SELECT DISTINCT + ON ("exif"."model") "exif"."model" AS "model" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."make" = $2 diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 17fff94f42ac8..2f0613b4d0398 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -39,6 +39,7 @@ FROM "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes", + "SessionEntity__SessionEntity_user"."profileChangedAt" AS "SessionEntity__SessionEntity_user_profileChangedAt", "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId", "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key", "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value" diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index 10af8d17dbddb..a19b698f765a4 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -27,6 +27,7 @@ FROM "SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId", "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", + "SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", @@ -93,6 +94,7 @@ FROM "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."libraryId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_libraryId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceId", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."type" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_type", + "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."status" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_status", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalPath", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbhash" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbhash", "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."encodedVideoPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_encodedVideoPath", @@ -156,7 +158,8 @@ FROM "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes" + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt" FROM "shared_links" "SharedLinkEntity" LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id" @@ -213,6 +216,7 @@ SELECT "SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId", "SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId", "SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type", + "SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status", "SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath", "SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash", "SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath", @@ -257,7 +261,8 @@ SELECT "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", - "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes" + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt" FROM "shared_links" "SharedLinkEntity" LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id" @@ -309,7 +314,8 @@ FROM "SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status", "SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt", "SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes", - "SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes" + "SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes", + "SharedLinkEntity__SharedLinkEntity_user"."profileChangedAt" AS "SharedLinkEntity__SharedLinkEntity_user_profileChangedAt" FROM "shared_links" "SharedLinkEntity" LEFT JOIN "users" "SharedLinkEntity__SharedLinkEntity_user" ON "SharedLinkEntity__SharedLinkEntity_user"."id" = "SharedLinkEntity"."userId" diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index 2c75786f975b9..ab0a6cc534bd0 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -15,7 +15,8 @@ SELECT "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", - "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" + "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", + "UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" FROM "users" "UserEntity" WHERE @@ -60,7 +61,8 @@ SELECT "user"."status" AS "user_status", "user"."updatedAt" AS "user_updatedAt", "user"."quotaSizeInBytes" AS "user_quotaSizeInBytes", - "user"."quotaUsageInBytes" AS "user_quotaUsageInBytes" + "user"."quotaUsageInBytes" AS "user_quotaUsageInBytes", + "user"."profileChangedAt" AS "user_profileChangedAt" FROM "users" "user" WHERE @@ -82,7 +84,8 @@ SELECT "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", - "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" + "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", + "UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" FROM "users" "UserEntity" WHERE @@ -106,7 +109,8 @@ SELECT "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", - "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes" + "UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", + "UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" FROM "users" "UserEntity" WHERE diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql new file mode 100644 index 0000000000000..e5b88ffef98d4 --- /dev/null +++ b/server/src/queries/view.repository.sql @@ -0,0 +1,79 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- ViewRepository.getAssetsByOriginalPath +SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."status" AS "asset_status", + "asset"."originalPath" AS "asset_originalPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "asset"."duplicateId" AS "asset_duplicateId", + "exifInfo"."assetId" AS "exifInfo_assetId", + "exifInfo"."description" AS "exifInfo_description", + "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", + "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", + "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", + "exifInfo"."orientation" AS "exifInfo_orientation", + "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", + "exifInfo"."modifyDate" AS "exifInfo_modifyDate", + "exifInfo"."timeZone" AS "exifInfo_timeZone", + "exifInfo"."latitude" AS "exifInfo_latitude", + "exifInfo"."longitude" AS "exifInfo_longitude", + "exifInfo"."projectionType" AS "exifInfo_projectionType", + "exifInfo"."city" AS "exifInfo_city", + "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", + "exifInfo"."autoStackId" AS "exifInfo_autoStackId", + "exifInfo"."state" AS "exifInfo_state", + "exifInfo"."country" AS "exifInfo_country", + "exifInfo"."make" AS "exifInfo_make", + "exifInfo"."model" AS "exifInfo_model", + "exifInfo"."lensModel" AS "exifInfo_lensModel", + "exifInfo"."fNumber" AS "exifInfo_fNumber", + "exifInfo"."focalLength" AS "exifInfo_focalLength", + "exifInfo"."iso" AS "exifInfo_iso", + "exifInfo"."exposureTime" AS "exifInfo_exposureTime", + "exifInfo"."profileDescription" AS "exifInfo_profileDescription", + "exifInfo"."colorspace" AS "exifInfo_colorspace", + "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", + "exifInfo"."rating" AS "exifInfo_rating", + "exifInfo"."fps" AS "exifInfo_fps" +FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" +WHERE + ( + ( + "asset"."isVisible" = $1 + AND "asset"."isArchived" = $2 + AND "asset"."ownerId" = $3 + ) + AND ( + "asset"."originalPath" LIKE $4 + AND "asset"."originalPath" NOT LIKE $5 + ) + ) + AND ("asset"."deletedAt" IS NULL) +ORDER BY + regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index fd3a89993a6bd..f7b4cb44aa976 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -57,22 +57,6 @@ export class AlbumRepository implements IAlbumRepository { return withoutDeletedUsers(album); } - @GenerateSql({ params: [[DummyValue.UUID]] }) - @ChunkedArray() - async getByIds(ids: string[]): Promise { - const albums = await this.repository.find({ - where: { - id: In(ids), - }, - relations: { - owner: true, - albumUsers: { user: true }, - }, - }); - - return albums.map((album) => withoutDeletedUsers(album)); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async getByAssetId(ownerId: string, assetId: string): Promise { const albums = await this.repository.find({ @@ -116,34 +100,6 @@ export class AlbumRepository implements IAlbumRepository { })); } - /** - * Returns the album IDs that have an invalid thumbnail, when: - * - Thumbnail references an asset outside the album - * - Empty album still has a thumbnail set - */ - @GenerateSql() - async getInvalidThumbnail(): Promise { - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = this.dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); - - const albumContainsThumbnail = albumHasAssets - .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); - - const albums = await this.repository - .createQueryBuilder('albums') - .select('albums.id') - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`) - .getMany(); - - return albums.map((album) => album.id); - } - @GenerateSql({ params: [DummyValue.UUID] }) async getOwned(ownerId: string): Promise { const albums = await this.repository.find({ @@ -199,15 +155,6 @@ export class AlbumRepository implements IAlbumRepository { await this.repository.delete({ ownerId: userId }); } - @GenerateSql() - getAll(): Promise { - return this.repository.find({ - relations: { - owner: true, - }, - }); - } - @GenerateSql({ params: [DummyValue.UUID] }) async removeAsset(assetId: string): Promise { // Using dataSource, because there is no direct access to albums_assets_assets. @@ -330,32 +277,26 @@ export class AlbumRepository implements IAlbumRepository { @GenerateSql() async updateThumbnails(): Promise { // Subquery for getting a new thumbnail. - const newThumbnail = this.assetRepository - .createQueryBuilder('assets') - .select('albums_assets2.assetsId') - .addFrom('albums_assets_assets', 'albums_assets2') - .where('albums_assets2.assetsId = assets.id') - .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query - .orderBy('assets.fileCreatedAt', 'DESC') - .limit(1); - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = this.dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); + const builder = this.dataSource + .createQueryBuilder('albums_assets_assets', 'album_assets') + .innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"') + .where('"album_assets"."albumsId" = "albums"."id"'); - const albumContainsThumbnail = albumHasAssets + const newThumbnail = builder .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); + .select('"album_assets"."assetsId"') + .orderBy('"assets"."fileCreatedAt"', 'DESC') + .limit(1); + const hasAssets = builder.clone().select('1'); + const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"'); const updateAlbums = this.repository .createQueryBuilder('albums') .update(AlbumEntity) .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); + .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`) + .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`); const result = await updateAlbums.execute(); diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 3763cccd53c5d..8bca755c32e26 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -6,14 +6,13 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; -import { AssetFileType, AssetOrder, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, PaginationMode } from 'src/enum'; import { AssetBuilderOptions, AssetCreate, AssetDeltaSyncOptions, AssetExploreFieldOptions, AssetFullSyncOptions, - AssetPathEntity, AssetStats, AssetStatsOptions, AssetUpdateAllOptions, @@ -31,7 +30,7 @@ import { import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { Brackets, FindOptionsOrder, @@ -177,14 +176,6 @@ export class AssetRepository implements IAssetRepository { return this.getAll(pagination, { ...options, userIds: [userId] }); } - @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) - getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { - return paginate(this.repository, pagination, { - select: { id: true, originalPath: true, isOffline: true }, - where: { library: { id: libraryId }, isExternal: true }, - }); - } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { return this.repository.findOne({ @@ -198,24 +189,16 @@ export class AssetRepository implements IAssetRepository { async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { const result = await this.repository.query( ` - WITH paths AS (SELECT unnest($2::text[]) AS path) - SELECT path FROM paths - WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); - `, + WITH paths AS (SELECT unnest($2::text[]) AS path) + SELECT path + FROM paths + WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); + `, [libraryId, originalPaths], ); return result.map((row: { path: string }) => row.path); } - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { - await this.repository.update( - { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, - { isOffline: true }, - ); - } - getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); @@ -295,16 +278,6 @@ export class AssetRepository implements IAssetRepository { .execute(); } - @Chunked() - async softDeleteAll(ids: string[]): Promise { - await this.repository.softDelete({ id: In(ids) }); - } - - @Chunked() - async restoreAll(ids: string[]): Promise { - await this.repository.restore({ id: In(ids) }); - } - async update(asset: AssetUpdateOptions): Promise { await this.repository.update(asset.id, asset); } @@ -338,6 +311,7 @@ export class AssetRepository implements IAssetRepository { select: { id: true, checksum: true, + deletedAt: true, }, where: { ownerId, @@ -382,12 +356,10 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql( - ...Object.values(WithProperty) - .filter((property) => property !== WithProperty.IS_OFFLINE && property !== WithProperty.IS_ONLINE) - .map((property) => ({ - name: property, - params: [DummyValue.PAGINATION, property], - })), + ...Object.values(WithProperty).map((property) => ({ + name: property, + params: [DummyValue.PAGINATION, property], + })), ) getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { let relations: FindOptionsRelations = {}; @@ -540,26 +512,16 @@ export class AssetRepository implements IAssetRepository { where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; break; } - case WithProperty.IS_OFFLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding offline assets'); - } - where = [{ isOffline: true, libraryId }]; - break; - } - case WithProperty.IS_ONLINE: { - if (!libraryId) { - throw new Error('Library id is required when finding online assets'); - } - where = [{ isOffline: false, libraryId }]; - break; - } default: { throw new Error(`Invalid getWith property: ${property}`); } } + if (libraryId) { + where = [{ ...where, libraryId }]; + } + return paginate(this.repository, pagination, { where, withDeleted, @@ -570,13 +532,6 @@ export class AssetRepository implements IAssetRepository { }); } - getFirstAssetForAlbumId(albumId: string): Promise { - return this.repository.findOne({ - where: { albums: { id: albumId } }, - order: { fileCreatedAt: 'DESC' }, - }); - } - getLastUpdatedAssetForAlbumId(albumId: string): Promise { return this.repository.findOne({ where: { albums: { id: albumId } }, @@ -603,7 +558,10 @@ export class AssetRepository implements IAssetRepository { } if (isTrashed !== undefined) { - builder.withDeleted().andWhere(`asset.deletedAt is not null`); + builder + .withDeleted() + .andWhere(`asset.deletedAt is not null`) + .andWhere('asset.status = :status', { status: AssetStatus.TRASHED }); } const items = await builder.getRawMany(); @@ -622,14 +580,9 @@ export class AssetRepository implements IAssetRepository { return result; } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) - getRandom(ownerId: string, count: number): Promise { - const builder = this.getBuilder({ - userIds: [ownerId], - exifInfo: true, - }); - - return builder.orderBy('RANDOM()').limit(count).getMany(); + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER] }) + getRandom(userIds: string[], count: number): Promise { + return this.getBuilder({ userIds, exifInfo: true }).orderBy('RANDOM()').limit(count).getMany(); } @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) @@ -766,6 +719,13 @@ export class AssetRepository implements IAssetRepository { if (options.isTrashed !== undefined) { builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); + + if (options.isTrashed) { + // TODO: Temporarily inverted to support showing offline assets in the trash queries. + // Once offline assets are handled in a separate screen, this should be set back to status = TRASHED + // and the offline screens should use a separate isOffline = true parameter in the timeline query. + builder.andWhere('asset.status != :status', { status: AssetStatus.DELETED }); + } } if (options.isDuplicate !== undefined) { @@ -840,52 +800,13 @@ export class AssetRepository implements IAssetRepository { return builder.getMany(); } - async getUniqueOriginalPaths(userId: string): Promise { - const builder = this.getBuilder({ - userIds: [userId], - exifInfo: false, - withStacked: false, - isArchived: false, - isTrashed: false, - }); - - const results = await builder - .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') - .getRawMany(); - - return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { - const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); - - const builder = this.getBuilder({ - userIds: [userId], - exifInfo: true, - withStacked: false, - isArchived: false, - isTrashed: false, - }); - - const assets = await builder - .where('asset.ownerId = :userId', { userId }) - .andWhere( - new Brackets((qb) => { - qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( - 'asset.originalPath NOT LIKE :notLikePath', - { notLikePath: `%${normalizedPath}/%/%` }, - ); - }), - ) - .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') - .getMany(); - - return assets; + @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) + async upsertFile(file: { assetId: string; type: AssetFileType; path: string }): Promise { + await this.fileRepository.upsert(file, { conflictPaths: ['assetId', 'type'] }); } @GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] }) - async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise { - await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] }); + async upsertFiles(files: { assetId: string; type: AssetFileType; path: string }[]): Promise { + await this.fileRepository.upsert(files, { conflictPaths: ['assetId', 'type'] }); } } diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts new file mode 100644 index 0000000000000..83d89c6e01d14 --- /dev/null +++ b/server/src/repositories/config.repository.spec.ts @@ -0,0 +1,76 @@ +import { ConfigRepository } from 'src/repositories/config.repository'; + +const getEnv = () => new ConfigRepository().getEnv(); + +describe('getEnv', () => { + beforeEach(() => { + delete process.env.IMMICH_WORKERS_INCLUDE; + delete process.env.IMMICH_WORKERS_EXCLUDE; + delete process.env.NO_COLOR; + }); + + it('should return default workers', () => { + const { workers } = getEnv(); + expect(workers).toEqual(['api', 'microservices']); + }); + + it('should return included workers', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api'; + const { workers } = getEnv(); + expect(workers).toEqual(['api']); + }); + + it('should excluded workers from defaults', () => { + process.env.IMMICH_WORKERS_EXCLUDE = 'api'; + const { workers } = getEnv(); + expect(workers).toEqual(['microservices']); + }); + + it('should exclude workers from include list', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; + process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices'; + const { workers } = getEnv(); + expect(workers).toEqual(['api']); + }); + + it('should remove whitespace from included workers before parsing', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices'; + const { workers } = getEnv(); + expect(workers).toEqual(['api', 'microservices']); + }); + + it('should remove whitespace from excluded workers before parsing', () => { + process.env.IMMICH_WORKERS_EXCLUDE = 'api, microservices'; + const { workers } = getEnv(); + expect(workers).toEqual([]); + }); + + it('should remove whitespace from included and excluded workers before parsing', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices, randomservice,randomservice2'; + process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices, randomservice2'; + const { workers } = getEnv(); + expect(workers).toEqual(['api']); + }); + + it('should throw error for invalid workers', () => { + process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; + expect(getEnv).toThrowError('Invalid worker(s) found: api,microservices,randomservice'); + }); + + it('should default noColor to false', () => { + const { noColor } = getEnv(); + expect(noColor).toBe(false); + }); + + it('should map NO_COLOR=1 to true', () => { + process.env.NO_COLOR = '1'; + const { noColor } = getEnv(); + expect(noColor).toBe(true); + }); + + it('should map NO_COLOR=true to true', () => { + process.env.NO_COLOR = 'true'; + const { noColor } = getEnv(); + expect(noColor).toBe(true); + }); +}); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts new file mode 100644 index 0000000000000..a9f9ca0c1ddb5 --- /dev/null +++ b/server/src/repositories/config.repository.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common'; +import { join } from 'node:path'; +import { citiesFile } from 'src/constants'; +import { ImmichEnvironment, ImmichWorker, LogLevel } from 'src/enum'; +import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { setDifference } from 'src/utils/set'; + +// TODO replace src/config validation with class-validator, here + +const productionKeys = { + client: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF2LzdTMzJjUkE1KysxTm5WRHNDTQpzcFAvakpISU1xT0pYRm5oNE53QTJPcHorUk1mZGNvOTJQc09naCt3d1FlRXYxVTJjMnBqelRpUS8ybHJLcS9rCnpKUmxYd2M0Y1Vlc1FETUpPRitQMnFPTlBiQUprWHZDWFlCVUxpdENJa29Md2ZoU0dOanlJS2FSRGhkL3ROeU4KOCtoTlJabllUMWhTSWo5U0NrS3hVQ096YXRQVjRtQ0RlclMrYkUrZ0VVZVdwOTlWOWF6dkYwRkltblRXcFFTdwpjOHdFWmdPTWg0c3ZoNmFpY3dkemtQQ3dFTGFrMFZhQkgzMUJFVUNRTGI5K0FJdEhBVXRKQ0t4aGI1V2pzMXM5CmJyWGZpMHZycGdjWi82RGFuWTJxZlNQem5PbXZEMkZycmxTMXE0SkpOM1ZvN1d3LzBZeS95TWNtelRXWmhHdWgKVVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', + server: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvcG5ZRGEwYS9kVTVJZUc3NGlFRQpNd2RBS2pzTmN6TGRDcVJkMVo5eTVUMndqTzdlWUlPZUpUc2wzNTBzUjBwNEtmU1VEU1h2QzlOcERwYzF0T0tsCjVzaEMvQXhwdlFBTENva0Y0anQ4dnJyZDlmQ2FYYzFUcVJiT21uaGl1Z0Q2dmtyME8vRmIzVURpM1UwVHZoUFAKbFBkdlNhd3pMcldaUExmbUhWVnJiclNLbW45SWVTZ3kwN3VrV1RJeUxzY2lOcnZuQnl3c0phUmVEdW9OV1BCSApVL21vMm1YYThtNHdNV2hpWGVoaUlPUXFNdVNVZ1BlQ3NXajhVVngxQ0dsUnpQREEwYlZOUXZlS1hXVnhjRUk2ClVMRWdKeTJGNDlsSDArYVlDbUJmN05FcjZWUTJXQjk1ZXZUS1hLdm4wcUlNN25nRmxjVUF3NmZ1VjFjTkNUSlMKNndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', +}; + +const stagingKeys = { + client: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuSUNyTm5jbGpPSC9JdTNtWVVaRQp0dGJLV1c3OGRuajl5M0U2ekk3dU1NUndEckdYWFhkTGhkUDFxSWtlZHh0clVVeUpCMWR4R04yQW91S082MlNGCldrbU9PTmNGQlRBWFZTdjhUNVY0S0VwWnFQYWEwaXpNaGxMaE5sRXEvY1ZKdllrWlh1Z2x6b1o3cG1nbzFSdHgKam1iRm5NNzhrYTFRUUJqOVdLaEw2eWpWRUl2MDdVS0lKWHBNTnNuS2g1V083MjZhYmMzSE9udTlETjY5VnFFRQo3dGZrUnRWNmx2U1NzMkFVMngzT255cHA4ek53b0lPTWRibGsyb09aWWROZzY0Y3l2SzJoU0FlU3NVMFRyOVc5Ckgra0Y5QlNCNlk0QXl0QlVkSmkrK2pMSW5HM2Q5cU9ieFVzTlYrN05mRkF5NjJkL0xNR0xSOC9OUFc0U0s3c0MKRlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', + server: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE3Sy8yd3ZLUS9NdU8ydi9MUm5saAoyUy9zTHhDOGJiTEw1UUlKOGowQ3BVZW40YURlY2dYMUpKUmtGNlpUVUtpNTdTbEhtS3RSM2JOTzJmdTBUUVg5Ck5WMEJzVzllZVB0MmlTMWl4VVFmTzRObjdvTjZzbEtac01qd29RNGtGRGFmM3VHTlZJc0dMb3UxVWRLUVhpeDEKUlRHcXVTb3NZVjNWRlk3Q1hGYTVWaENBL3poVXNsNGFuVXp3eEF6M01jUFVlTXBaenYvbVZiQlRKVzBPSytWZgpWQUJvMXdYMkVBanpBekVHVzQ3Vko4czhnMnQrNHNPaHFBNStMQjBKVzlORUg5QUpweGZzWE4zSzVtM00yNUJVClZXcTlRYStIdHRENnJ0bnAvcUFweXVkWUdwZk9HYTRCUlZTR1MxMURZM0xrb2FlRzYwUEU5NHpoYjduOHpMWkgKelFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=', +}; + +const WORKER_TYPES = new Set(Object.values(ImmichWorker)); + +const asSet = (value: string | undefined, defaults: ImmichWorker[]) => { + const values = (value || '').replaceAll(/\s/g, '').split(',').filter(Boolean); + return new Set(values.length === 0 ? defaults : (values as ImmichWorker[])); +}; + +@Injectable() +export class ConfigRepository implements IConfigRepository { + getEnv(): EnvData { + const included = asSet(process.env.IMMICH_WORKERS_INCLUDE, [ImmichWorker.API, ImmichWorker.MICROSERVICES]); + const excluded = asSet(process.env.IMMICH_WORKERS_EXCLUDE, []); + const workers = [...setDifference(included, excluded)]; + for (const worker of workers) { + if (!WORKER_TYPES.has(worker)) { + throw new Error(`Invalid worker(s) found: ${workers.join(',')}`); + } + } + + const environment = process.env.IMMICH_ENV as ImmichEnvironment; + const isProd = environment === ImmichEnvironment.PRODUCTION; + const buildFolder = process.env.IMMICH_BUILD_DATA || '/build'; + const folders = { + geodata: join(buildFolder, 'geodata'), + web: join(buildFolder, 'www'), + }; + + return { + port: Number(process.env.IMMICH_PORT) || 3001, + environment, + configFile: process.env.IMMICH_CONFIG_FILE, + logLevel: process.env.IMMICH_LOG_LEVEL as LogLevel, + + buildMetadata: { + build: process.env.IMMICH_BUILD, + buildUrl: process.env.IMMICH_BUILD_URL, + buildImage: process.env.IMMICH_BUILD_IMAGE, + buildImageUrl: process.env.IMMICH_BUILD_IMAGE_URL, + repository: process.env.IMMICH_REPOSITORY, + repositoryUrl: process.env.IMMICH_REPOSITORY_URL, + sourceRef: process.env.IMMICH_SOURCE_REF, + sourceCommit: process.env.IMMICH_SOURCE_COMMIT, + sourceUrl: process.env.IMMICH_SOURCE_URL, + thirdPartySourceUrl: process.env.IMMICH_THIRD_PARTY_SOURCE_URL, + thirdPartyBugFeatureUrl: process.env.IMMICH_THIRD_PARTY_BUG_FEATURE_URL, + thirdPartyDocumentationUrl: process.env.IMMICH_THIRD_PARTY_DOCUMENTATION_URL, + thirdPartySupportUrl: process.env.IMMICH_THIRD_PARTY_SUPPORT_URL, + }, + + database: { + url: process.env.DB_URL, + host: process.env.DB_HOSTNAME || 'database', + port: Number(process.env.DB_PORT) || 5432, + username: process.env.DB_USERNAME || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + name: process.env.DB_DATABASE_NAME || 'immich', + + skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true', + vectorExtension: + process.env.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS, + }, + + licensePublicKey: isProd ? productionKeys : stagingKeys, + + resourcePaths: { + lockFile: join(buildFolder, 'build-lock.json'), + geodata: { + dateFile: join(folders.geodata, 'geodata-date.txt'), + admin1: join(folders.geodata, 'admin1CodesASCII.txt'), + admin2: join(folders.geodata, 'admin2Codes.txt'), + cities500: join(folders.geodata, citiesFile), + naturalEarthCountriesPath: join(folders.geodata, 'ne_10m_admin_0_countries.geojson'), + }, + web: { + root: folders.web, + indexHtml: join(folders.web, 'index.html'), + }, + }, + + storage: { + ignoreMountCheckErrors: process.env.IMMICH_IGNORE_MOUNT_CHECK_ERRORS === 'true', + }, + + workers, + + noColor: !!process.env.NO_COLOR, + }; + } +} diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 0453421a39d1b..76998b523948f 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -3,7 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import AsyncLock from 'async-lock'; import semver from 'semver'; import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; -import { getVectorExtension } from 'src/database.config'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, DatabaseLock, @@ -22,12 +22,15 @@ import { DataSource, EntityManager, QueryRunner } from 'typeorm'; @Instrumentation() @Injectable() export class DatabaseRepository implements IDatabaseRepository { + private vectorExtension: VectorExtension; readonly asyncLock = new AsyncLock(); constructor( @InjectDataSource() private dataSource: DataSource, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(IConfigRepository) configRepository: IConfigRepository, ) { + this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(DatabaseRepository.name); } @@ -119,7 +122,7 @@ export class DatabaseRepository implements IDatabaseRepository { try { await this.dataSource.query(`REINDEX INDEX ${index}`); } catch (error) { - if (getVectorExtension() !== DatabaseExtension.VECTORS) { + if (this.vectorExtension !== DatabaseExtension.VECTORS) { throw error; } this.logger.warn(`Could not reindex index ${index}. Attempting to auto-fix.`); @@ -141,7 +144,7 @@ export class DatabaseRepository implements IDatabaseRepository { } async shouldReindex(name: VectorIndex): Promise { - if (getVectorExtension() !== DatabaseExtension.VECTORS) { + if (this.vectorExtension !== DatabaseExtension.VECTORS) { return false; } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 9aa12e15dd560..cb58d56b2ad72 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; -import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ModuleRef, Reflector } from '@nestjs/core'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -8,21 +7,35 @@ import { WebSocketGateway, WebSocketServer, } from '@nestjs/websockets'; +import { ClassConstructor } from 'class-transformer'; +import _ from 'lodash'; import { Server, Socket } from 'socket.io'; +import { EventConfig } from 'src/decorators'; +import { MetadataKey } from 'src/enum'; import { ArgsOf, ClientEventMap, EmitEvent, EmitHandler, + EventItem, IEventRepository, - ServerEvent, - ServerEventMap, + serverEvents, + ServerEvents, } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { Instrumentation } from 'src/utils/instrumentation'; +import { handlePromiseError } from 'src/utils/misc'; -type EmitHandlers = Partial<{ [T in EmitEvent]: EmitHandler[] }>; +type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; + +type Item = { + event: T; + handler: EmitHandler; + priority: number; + server: boolean; + label: string; +}; @Instrumentation() @WebSocketGateway({ @@ -39,23 +52,61 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect constructor( private moduleRef: ModuleRef, - private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(EventRepository.name); } - afterInit(server: Server) { - this.logger.log('Initialized websocket server'); + setup({ services }: { services: ClassConstructor[] }) { + const reflector = this.moduleRef.get(Reflector, { strict: false }); + const repository = this.moduleRef.get(IEventRepository); + const items: Item[] = []; - for (const event of Object.values(ServerEvent)) { - if (event === ServerEvent.WEBSOCKET_CONNECT) { - continue; + // discovery + for (const Service of services) { + const instance = this.moduleRef.get(Service); + const ctx = Object.getPrototypeOf(instance); + for (const property of Object.getOwnPropertyNames(ctx)) { + const descriptor = Object.getOwnPropertyDescriptor(ctx, property); + if (!descriptor || descriptor.get || descriptor.set) { + continue; + } + + const handler = instance[property]; + if (typeof handler !== 'function') { + continue; + } + + const event = reflector.get(MetadataKey.EVENT_CONFIG, handler); + if (!event) { + continue; + } + + items.push({ + event: event.name, + priority: event.priority || 0, + server: event.server ?? false, + handler: handler.bind(instance), + label: `${Service.name}.${handler.name}`, + }); } + } + + const handlers = _.orderBy(items, ['priority'], ['asc']); - server.on(event, (data: unknown) => { + // register by priority + for (const handler of handlers) { + repository.on(handler); + } + } + + afterInit(server: Server) { + this.logger.log('Initialized websocket server'); + + for (const event of serverEvents) { + server.on(event, (...args: ArgsOf) => { this.logger.debug(`Server event: ${event} (receive)`); - this.eventEmitter.emit(event, data); + handlePromiseError(this.onEvent({ name: event, args, server: true }), this.logger); }); } } @@ -72,7 +123,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect if (auth.session) { await client.join(auth.session.id); } - this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); + await this.onEvent({ name: 'websocket.connect', args: [{ userId: auth.user.id }], server: false }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); client.emit('error', 'unauthorized'); @@ -85,32 +136,42 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect await client.leave(client.nsp.name); } - on(event: T, handler: EmitHandler): void { + on(item: EventItem): void { + const event = item.event; + if (!this.emitHandlers[event]) { this.emitHandlers[event] = []; } - this.emitHandlers[event].push(handler); + this.emitHandlers[event].push(item); } async emit(event: T, ...args: ArgsOf): Promise { - const handlers = this.emitHandlers[event] || []; - for (const handler of handlers) { - await handler(...args); + return this.onEvent({ name: event, args, server: false }); + } + + private async onEvent(event: { name: T; args: ArgsOf; server: boolean }): Promise { + const handlers = this.emitHandlers[event.name] || []; + for (const { handler, server } of handlers) { + // exclude handlers that ignore server events + if (!server && event.server) { + continue; + } + + await handler(...event.args); } } - clientSend(event: E, room: string, data: ClientEventMap[E]) { - this.server?.to(room).emit(event, data); + clientSend(event: T, room: string, ...data: ClientEventMap[T]) { + this.server?.to(room).emit(event, ...data); } - clientBroadcast(event: E, data: ClientEventMap[E]) { - this.server?.emit(event, data); + clientBroadcast(event: T, ...data: ClientEventMap[T]) { + this.server?.emit(event, ...data); } - serverSend(event: E, data: ServerEventMap[E]) { + serverSend(event: T, ...args: ArgsOf): void { this.logger.debug(`Server event: ${event} (send)`); - this.server?.serverSideEmit(event, data); - return this.eventEmitter.emit(event, data); + this.server?.serverSideEmit(event, ...args); } } diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 3be6b375a087d..5bf08d0d78f1a 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,6 +5,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; @@ -19,6 +20,7 @@ import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IMetricRepository } from 'src/interfaces/metric.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { INotificationRepository } from 'src/interfaces/notification.interface'; +import { IOAuthRepository } from 'src/interfaces/oauth.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; @@ -29,7 +31,10 @@ import { IStackRepository } from 'src/interfaces/stack.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +import { IViewRepository } from 'src/interfaces/view.interface'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -37,6 +42,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { EventRepository } from 'src/repositories/event.repository'; @@ -51,6 +57,7 @@ import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetricRepository } from 'src/repositories/metric.repository'; import { MoveRepository } from 'src/repositories/move.repository'; import { NotificationRepository } from 'src/repositories/notification.repository'; +import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { SearchRepository } from 'src/repositories/search.repository'; @@ -61,7 +68,10 @@ import { StackRepository } from 'src/repositories/stack.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { TagRepository } from 'src/repositories/tag.repository'; +import { TrashRepository } from 'src/repositories/trash.repository'; import { UserRepository } from 'src/repositories/user.repository'; +import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { ViewRepository } from 'src/repositories/view-repository'; export const repositories = [ { provide: IAccessRepository, useClass: AccessRepository }, @@ -70,6 +80,7 @@ export const repositories = [ { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, { provide: IAuditRepository, useClass: AuditRepository }, + { provide: IConfigRepository, useClass: ConfigRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IEventRepository, useClass: EventRepository }, @@ -85,6 +96,7 @@ export const repositories = [ { provide: IMetricRepository, useClass: MetricRepository }, { provide: IMoveRepository, useClass: MoveRepository }, { provide: INotificationRepository, useClass: NotificationRepository }, + { provide: IOAuthRepository, useClass: OAuthRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, { provide: ISearchRepository, useClass: SearchRepository }, @@ -95,5 +107,8 @@ export const repositories = [ { provide: IStorageRepository, useClass: StorageRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, + { provide: ITrashRepository, useClass: TrashRepository }, { provide: IUserRepository, useClass: UserRepository }, + { provide: IVersionHistoryRepository, useClass: VersionHistoryRepository }, + { provide: IViewRepository, useClass: ViewRepository }, ]; diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index f64e5175e5127..3f154ee01615a 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -36,11 +36,12 @@ export const JOBS_TO_QUEUE: Record = { // thumbnails [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_PREVIEW]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, - [JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION, + [JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, + // tags + [JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK, + // metadata [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, @@ -76,12 +77,12 @@ export const JOBS_TO_QUEUE: Record = { [JobName.SIDECAR_WRITE]: QueueName.SIDECAR, // Library management - [JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY, - [JobName.LIBRARY_SCAN]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY, [JobName.LIBRARY_DELETE]: QueueName.LIBRARY, - [JobName.LIBRARY_CHECK_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY, - [JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY, + [JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY, + [JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY, [JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY, // Notification @@ -92,6 +93,9 @@ export const JOBS_TO_QUEUE: Record = { // Version check [JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK, + + // Trash + [JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK, }; @Instrumentation() @@ -150,10 +154,6 @@ export class JobRepository implements IJobRepository { } } - deleteCronJob(name: string): void { - this.schedulerReqistry.deleteCronJob(name); - } - setConcurrency(queueName: QueueName, concurrency: number) { const worker = this.workers[queueName]; if (!worker) { diff --git a/server/src/repositories/logger.repository.spec.ts b/server/src/repositories/logger.repository.spec.ts new file mode 100644 index 0000000000000..dcb54ada7c003 --- /dev/null +++ b/server/src/repositories/logger.repository.spec.ts @@ -0,0 +1,40 @@ +import { ClsService } from 'nestjs-cls'; +import { ImmichWorker } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { LoggerRepository } from 'src/repositories/logger.repository'; +import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; +import { Mocked } from 'vitest'; + +describe(LoggerRepository.name, () => { + let sut: LoggerRepository; + + let configMock: Mocked; + let clsMock: Mocked; + + beforeEach(() => { + configMock = newConfigRepositoryMock(); + clsMock = { + getId: vitest.fn(), + } as unknown as Mocked; + }); + + describe('formatContext', () => { + it('should use colors', () => { + configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false })); + + sut = new LoggerRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); + + expect(sut['formatContext']('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); + }); + + it('should not use colors when noColor is true', () => { + configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true })); + + sut = new LoggerRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); + + expect(sut['formatContext']('context')).toBe('[Api:context] '); + }); + }); +}); diff --git a/server/src/repositories/logger.repository.ts b/server/src/repositories/logger.repository.ts index 1e0c7b74d973e..2023cd6c4307a 100644 --- a/server/src/repositories/logger.repository.ts +++ b/server/src/repositories/logger.repository.ts @@ -1,32 +1,48 @@ -import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'; +import { ConsoleLogger, Inject, Injectable, Scope } from '@nestjs/common'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { ClsService } from 'nestjs-cls'; -import { LogLevel } from 'src/config'; +import { LogLevel } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { LogColor } from 'src/utils/logger'; const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; +enum LogColor { + RED = 31, + GREEN = 32, + YELLOW = 33, + BLUE = 34, + MAGENTA_BRIGHT = 95, + CYAN_BRIGHT = 96, +} + @Injectable({ scope: Scope.TRANSIENT }) export class LoggerRepository extends ConsoleLogger implements ILoggerRepository { private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; + private noColor: boolean; - constructor(private cls: ClsService) { + constructor( + private cls: ClsService, + @Inject(IConfigRepository) configRepository: IConfigRepository, + ) { super(LoggerRepository.name); + + const { noColor } = configRepository.getEnv(); + this.noColor = noColor; } private static appName?: string = undefined; setAppName(name: string): void { - LoggerRepository.appName = name; + LoggerRepository.appName = name.charAt(0).toUpperCase() + name.slice(1); } isLevelEnabled(level: LogLevel) { return isLogLevelEnabled(level, LoggerRepository.logLevels); } - setLogLevel(level: LogLevel): void { - LoggerRepository.logLevels = LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)); + setLogLevel(level: LogLevel | false): void { + LoggerRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; } protected formatContext(context: string): string { @@ -44,6 +60,19 @@ export class LoggerRepository extends ConsoleLogger implements ILoggerRepository return ''; } - return LogColor.yellow(`[${prefix}]`) + ' '; + return this.colors.yellow(`[${prefix}]`) + ' '; + } + + private colors = { + red: (text: string) => this.withColor(text, LogColor.RED), + green: (text: string) => this.withColor(text, LogColor.GREEN), + yellow: (text: string) => this.withColor(text, LogColor.YELLOW), + blue: (text: string) => this.withColor(text, LogColor.BLUE), + magentaBright: (text: string) => this.withColor(text, LogColor.MAGENTA_BRIGHT), + cyanBright: (text: string) => this.withColor(text, LogColor.CYAN_BRIGHT), + }; + + private withColor(text: string, color: LogColor) { + return this.noColor ? text : `\u001B[${color}m${text}\u001B[39m`; } } diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index da4e30d47cbf8..8ba9b4cab8c65 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -4,11 +4,12 @@ import { getName } from 'i18n-iso-countries'; import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import readLine from 'node:readline'; -import { citiesFile, resourcePaths } from 'src/constants'; +import { citiesFile } from 'src/constants'; import { AssetEntity } from 'src/entities/asset.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { NaturalEarthCountriesEntity } from 'src/entities/natural-earth-countries.entity'; import { SystemMetadataKey } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { GeoPoint, @@ -32,6 +33,7 @@ export class MapRepository implements IMapRepository { @InjectRepository(NaturalEarthCountriesEntity) private naturalEarthCountriesRepository: Repository, @InjectDataSource() private dataSource: DataSource, + @Inject(IConfigRepository) private configRepository: IConfigRepository, @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -40,6 +42,7 @@ export class MapRepository implements IMapRepository { async init(): Promise { this.logger.log('Initializing metadata repository'); + const { resourcePaths } = this.configRepository.getEnv(); const geodataDate = await readFile(resourcePaths.geodata.dateFile, 'utf8'); // TODO move to service init @@ -124,7 +127,7 @@ export class MapRepository implements IMapRepository { } } - async reverseGeocode(point: GeoPoint): Promise { + async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); const response = await this.geodataPlacesRepository @@ -159,7 +162,7 @@ export class MapRepository implements IMapRepository { `Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`, ); - return null; + return { country: null, state: null, city: null }; } this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); @@ -181,6 +184,8 @@ export class MapRepository implements IMapRepository { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); + const { resourcePaths } = this.configRepository.getEnv(); + try { await queryRunner.startTransaction(); await queryRunner.manager.clear(NaturalEarthCountriesEntity); @@ -225,6 +230,7 @@ export class MapRepository implements IMapRepository { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); + const { resourcePaths } = this.configRepository.getEnv(); const admin1 = await this.loadAdmin(resourcePaths.geodata.admin1); const admin2 = await this.loadAdmin(resourcePaths.geodata.admin2); @@ -280,6 +286,7 @@ export class MapRepository implements IMapRepository { admin1Map: Map, admin2Map: Map, ) { + const { resourcePaths } = this.configRepository.getEnv(); await this.loadGeodataToTableFromFile( queryRunner, (lineSplit: string[]) => diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a84ef6f596f4e..0777ca3479a98 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,26 +1,41 @@ import { Inject, Injectable } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; +import { Duration } from 'luxon'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; -import { promisify } from 'node:util'; import sharp from 'sharp'; -import { Colorspace } from 'src/config'; +import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { + DecodeToBufferOptions, + GenerateThumbhashOptions, + GenerateThumbnailOptions, IMediaRepository, ImageDimensions, - ThumbnailOptions, + ProbeOptions, TranscodeCommand, VideoInfo, } from 'src/interfaces/media.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { handlePromiseError } from 'src/utils/misc'; -const probe = promisify(ffmpeg.ffprobe); +const probe = (input: string, options: string[]): Promise => + new Promise((resolve, reject) => + ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))), + ); sharp.concurrency(0); sharp.cache({ files: 0 }); +type ProgressEvent = { + frames: number; + currentFps: number; + currentKbps: number; + targetSize: number; + timemark: string; + percent?: number; +}; + @Instrumentation() @Injectable() export class MediaRepository implements IMediaRepository { @@ -44,19 +59,12 @@ export class MediaRepository implements IMediaRepository { return true; } - async generateThumbnail(input: string | Buffer, output: string, options: ThumbnailOptions): Promise { - // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes - const pipeline = sharp(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .rotate(); - - if (options.crop) { - pipeline.extract(options.crop); - } + decodeImage(input: string, options: DecodeToBufferOptions) { + return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true }); + } - await pipeline - .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }) - .withIccProfile(options.colorspace) + async generateThumbnail(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { + await this.getImageDecodingPipeline(input, options) .toFormat(options.format, { quality: options.quality, // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp @@ -65,8 +73,41 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } - async probe(input: string): Promise { - const results = await probe(input); + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { + let pipeline = sharp(input, { + // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes + failOn: options.processInvalidImages ? 'none' : 'error', + limitInputPixels: false, + raw: options.raw, + }) + .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + .withIccProfile(options.colorspace); + + if (!options.raw) { + pipeline = pipeline.rotate(); + } + + if (options.crop) { + pipeline = pipeline.extract(options.crop); + } + + return pipeline.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true }); + } + + async generateThumbhash(input: string | Buffer, options: GenerateThumbhashOptions): Promise { + const [{ rgbaToThumbHash }, { data, info }] = await Promise.all([ + import('thumbhash'), + sharp(input, options) + .resize(100, 100, { fit: 'inside', withoutEnlargement: true }) + .raw() + .ensureAlpha() + .toBuffer({ resolveWithObject: true }), + ]); + return Buffer.from(rgbaToThumbHash(info.width, info.height, data)); + } + + async probe(input: string, options?: ProbeOptions): Promise { + const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { format: { formatName: results.format.format_name, @@ -83,10 +124,10 @@ export class MediaRepository implements IMediaRepository { width: stream.width || 0, codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, codecType: stream.codec_type, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), - rotation: Number.parseInt(`${stream.rotation ?? 0}`), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: Number.parseInt(stream.bit_rate ?? '0'), + bitrate: this.parseInt(stream.bit_rate), })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') @@ -94,7 +135,7 @@ export class MediaRepository implements IMediaRepository { index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), })), }; } @@ -137,29 +178,43 @@ export class MediaRepository implements IMediaRepository { }); } - async generateThumbhash(imagePath: string): Promise { - const maxSize = 100; - - const { data, info } = await sharp(imagePath) - .resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true }) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - - const thumbhash = await import('thumbhash'); - return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); - } - async getImageDimensions(input: string): Promise { const { width = 0, height = 0 } = await sharp(input).metadata(); return { width, height }; } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { - return ffmpeg(input, { niceness: 10 }) + const ffmpegCall = ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) .outputOptions(options.outputOptions) .output(output) - .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); + .on('start', (command: string) => this.logger.debug(command)) + .on('error', (error, _, stderr) => this.logger.error(stderr || error)); + + const { frameCount, percentInterval } = options.progress; + const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); + if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) { + let lastProgressFrame: number = 0; + ffmpegCall.on('progress', (progress: ProgressEvent) => { + if (progress.frames - lastProgressFrame < frameInterval) { + return; + } + + lastProgressFrame = progress.frames; + const percent = ((progress.frames / frameCount) * 100).toFixed(2); + const ms = Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000; + const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : ''; + const outputText = output instanceof Writable ? 'stream' : output.split('/').pop(); + this.logger.debug( + `Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`, + ); + }); + } + + return ffmpegCall; + } + + private parseInt(value: string | number | undefined): number { + return Number.parseInt(value as string) || 0; } } diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index abffc1b78527a..dc2a4cdf9bd1f 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,13 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; -import { DummyValue, GenerateSql } from 'src/decorators'; -import { ExifEntity } from 'src/entities/exif.entity'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; @Instrumentation() @Injectable() @@ -16,6 +12,7 @@ export class MetadataRepository implements IMetadataRepository { defaultVideosToUTC: true, backfillTimezones: true, inferTimezoneFromDatestamps: true, + inferTimezoneFromTimeStamp: true, useMWG: true, numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength'], /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ @@ -25,10 +22,7 @@ export class MetadataRepository implements IMetadataRepository { writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], }); - constructor( - @InjectRepository(ExifEntity) private exifRepository: Repository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { this.logger.setContext(MetadataRepository.name); } @@ -36,11 +30,11 @@ export class MetadataRepository implements IMetadataRepository { await this.exiftool.end(); } - readTags(path: string): Promise { + readTags(path: string): Promise { return this.exiftool.read(path).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); - return null; - }) as Promise; + return {}; + }) as Promise; } extractBinaryTag(path: string, tagName: string): Promise { @@ -54,91 +48,4 @@ export class MetadataRepository implements IMetadataRepository { this.logger.warn(`Error writing exif data (${path}): ${error}`); } } - - @GenerateSql({ params: [DummyValue.UUID] }) - async getCountries(userIds: string[]): Promise { - const results = await this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.country', 'country') - .distinctOn(['exif.country']) - .getRawMany<{ country: string }>(); - - return results.map(({ country }) => country).filter((item) => item !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getStates(userIds: string[], country: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.state', 'state') - .distinctOn(['exif.state']); - - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - const result = await query.getRawMany<{ state: string }>(); - - return result.map(({ state }) => state).filter((item) => item !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) - async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.city', 'city') - .distinctOn(['exif.city']); - - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - if (state) { - query.andWhere('exif.state = :state', { state }); - } - - const results = await query.getRawMany<{ city: string }>(); - - return results.map(({ city }) => city).filter((item) => item !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getCameraMakes(userIds: string[], model: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.make', 'make') - .distinctOn(['exif.make']); - - if (model) { - query.andWhere('exif.model = :model', { model }); - } - - const results = await query.getRawMany<{ make: string }>(); - return results.map(({ make }) => make).filter((item) => item !== ''); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getCameraModels(userIds: string[], make: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.model', 'model') - .distinctOn(['exif.model']); - - if (make) { - query.andWhere('exif.make = :make', { make }); - } - - const results = await query.getRawMany<{ model: string }>(); - return results.map(({ model }) => model).filter((item) => item !== ''); - } } diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index a8416ff0ac4c6..45fd4465265d7 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { MoveEntity, PathType } from 'src/entities/move.entity'; +import { MoveEntity } from 'src/entities/move.entity'; +import { PathType } from 'src/enum'; import { IMoveRepository, MoveCreate } from 'src/interfaces/move.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts new file mode 100644 index 0000000000000..adde7099d0720 --- /dev/null +++ b/server/src/repositories/oauth.repository.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { custom, generators, Issuer } from 'openid-client'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IOAuthRepository, OAuthConfig, OAuthProfile } from 'src/interfaces/oauth.interface'; +import { Instrumentation } from 'src/utils/instrumentation'; + +@Instrumentation() +@Injectable() +export class OAuthRepository implements IOAuthRepository { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + this.logger.setContext(OAuthRepository.name); + } + + init() { + custom.setHttpOptionsDefaults({ timeout: 30_000 }); + } + + async authorize(config: OAuthConfig, redirectUrl: string) { + const client = await this.getClient(config); + return client.authorizationUrl({ + redirect_uri: redirectUrl, + scope: config.scope, + state: generators.state(), + }); + } + + async getLogoutEndpoint(config: OAuthConfig) { + const client = await this.getClient(config); + return client.issuer.metadata.end_session_endpoint; + } + + async getProfile(config: OAuthConfig, url: string, redirectUrl: string): Promise { + const client = await this.getClient(config); + const params = client.callbackParams(url); + try { + const tokens = await client.callback(redirectUrl, params, { state: params.state }); + return await client.userinfo(tokens.access_token || ''); + } catch (error: Error | any) { + if (error.message.includes('unexpected JWT alg received')) { + this.logger.warn( + [ + 'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.', + 'Or, that you have specified a signing key in your OAuth provider.', + ].join(' '), + ); + } + + throw error; + } + } + + private async getClient({ + issuerUrl, + clientId, + clientSecret, + profileSigningAlgorithm, + signingAlgorithm, + }: OAuthConfig) { + try { + const issuer = await Issuer.discover(issuerUrl); + return new issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + response_types: ['code'], + userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm, + id_token_signed_response_alg: signingAlgorithm, + }); + } catch (error: any | AggregateError) { + this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors); + throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error }); + } + } +} diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 1290df740e62d..3ba9e238872b9 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -5,21 +5,23 @@ import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { SourceType } from 'src/enum'; +import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, - DeleteAllFacesOptions, + DeleteFacesOptions, IPersonRepository, PeopleStatistics, PersonNameResponse, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; @Instrumentation() @@ -30,6 +32,7 @@ export class PersonRepository implements IPersonRepository { @InjectRepository(AssetEntity) private assetRepository: Repository, @InjectRepository(PersonEntity) private personRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, + @InjectRepository(FaceSearchEntity) private faceSearchRepository: Repository, @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, ) {} @@ -39,12 +42,23 @@ export class PersonRepository implements IPersonRepository { .createQueryBuilder() .update() .set({ personId: newPersonId }) - .where(_.omitBy({ personId: oldPersonId ?? undefined, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) + .where(_.omitBy({ personId: oldPersonId, id: faceIds ? In(faceIds) : undefined }, _.isUndefined)) .execute(); return result.affected ?? 0; } + async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { + await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: null }) + .where({ sourceType }) + .execute(); + + await this.vacuum({ reindexVectors: false }); + } + async delete(entities: PersonEntity[]): Promise { await this.personRepository.remove(entities); } @@ -53,21 +67,14 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.clear(); } - async deleteAllFaces({ sourceType }: DeleteAllFacesOptions): Promise { - if (!sourceType) { - return this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE'); - } - + async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') .delete() .andWhere('sourceType = :sourceType', { sourceType }) .execute(); - await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search'); - if (sourceType === SourceType.MACHINE_LEARNING) { - await this.assetFaceRepository.query('REINDEX INDEX face_index'); - } + await this.vacuum({ reindexVectors: sourceType === SourceType.MACHINE_LEARNING }); } getAllFaces( @@ -184,14 +191,11 @@ export class PersonRepository implements IPersonRepository { getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { const queryBuilder = this.personRepository .createQueryBuilder('person') - .leftJoin('person.faces', 'face') .where( 'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)', { userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` }, ) - .groupBy('person.id') - .orderBy('COUNT(face.assetId)', 'DESC') - .limit(20); + .limit(1000); if (!withHidden) { queryBuilder.andWhere('person.isHidden = false'); @@ -230,30 +234,6 @@ export class PersonRepository implements IPersonRepository { }; } - @GenerateSql({ params: [DummyValue.UUID] }) - getAssets(personId: string): Promise { - return this.assetRepository.find({ - where: { - faces: { - personId, - }, - isVisible: true, - isArchived: false, - }, - relations: { - faces: { - person: true, - }, - exifInfo: true, - }, - order: { - fileCreatedAt: 'desc', - }, - // TODO: remove after either (1) pagination or (2) time bucket is implemented for this query - take: 1000, - }); - } - @GenerateSql({ params: [DummyValue.UUID] }) async getNumberOfPeople(userId: string): Promise { const items = await this.personRepository @@ -280,8 +260,13 @@ export class PersonRepository implements IPersonRepository { return result; } - create(entities: Partial[]): Promise { - return this.personRepository.save(entities); + create(person: Partial): Promise { + return this.save(person); + } + + async createAll(people: Partial[]): Promise { + const results = await this.personRepository.save(people); + return results.map((person) => person.id); } async createFaces(entities: AssetFaceEntity[]): Promise { @@ -289,16 +274,40 @@ export class PersonRepository implements IPersonRepository { return res.map((row) => row.id); } - async replaceFaces(assetId: string, entities: AssetFaceEntity[], sourceType: string): Promise { - return this.dataSource.transaction(async (manager) => { - await manager.delete(AssetFaceEntity, { assetId, sourceType }); - const assetFaces = await manager.save(AssetFaceEntity, entities); - return assetFaces.map(({ id }) => id); - }); + async refreshFaces( + facesToAdd: Partial[], + faceIdsToRemove: string[], + embeddingsToAdd?: FaceSearchEntity[], + ): Promise { + const query = this.faceSearchRepository.createQueryBuilder().select('1').fromDummy(); + if (facesToAdd.length > 0) { + const insertCte = this.assetFaceRepository.createQueryBuilder().insert().values(facesToAdd); + query.addCommonTableExpression(insertCte, 'added'); + } + + if (faceIdsToRemove.length > 0) { + const deleteCte = this.assetFaceRepository + .createQueryBuilder() + .delete() + .where('id = any(:faceIdsToRemove)', { faceIdsToRemove }); + query.addCommonTableExpression(deleteCte, 'deleted'); + } + + if (embeddingsToAdd?.length) { + const embeddingCte = this.faceSearchRepository.createQueryBuilder().insert().values(embeddingsToAdd).orIgnore(); + query.addCommonTableExpression(embeddingCte, 'embeddings'); + query.getQuery(); // typeorm mixes up parameters without this + } + + await query.execute(); } - async update(entities: Partial[]): Promise { - return await this.personRepository.save(entities); + async update(person: Partial): Promise { + return this.save(person); + } + + async updateAll(people: Partial[]): Promise { + await this.personRepository.save(people); } @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @@ -320,4 +329,18 @@ export class PersonRepository implements IPersonRepository { .getRawOne(); return result?.latestDate; } + + private async save(person: Partial): Promise { + const { id } = await this.personRepository.save(person); + return this.personRepository.findOneByOrFail({ id }); + } + + private async vacuum({ reindexVectors }: { reindexVectors: boolean }): Promise { + await this.assetFaceRepository.query('VACUUM ANALYZE asset_faces, face_search, person'); + await this.assetFaceRepository.query('REINDEX TABLE asset_faces'); + await this.assetFaceRepository.query('REINDEX TABLE person'); + if (reindexVectors) { + await this.assetFaceRepository.query('REINDEX TABLE face_search'); + } + } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 40f87ddf242c7..882a2634bd9dd 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,14 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { getVectorExtension } from 'src/database.config'; +import { randomUUID } from 'node:crypto'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; +import { ExifEntity } from 'src/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; -import { AssetType } from 'src/enum'; -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { AssetType, PaginationMode } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetDuplicateResult, @@ -22,24 +24,28 @@ import { } from 'src/interfaces/search.interface'; import { asVector, searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationResult, paginatedBuilder } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; -import { Repository, SelectQueryBuilder } from 'typeorm'; +import { Repository } from 'typeorm'; @Instrumentation() @Injectable() export class SearchRepository implements ISearchRepository { + private vectorExtension: VectorExtension; private faceColumns: string[]; private assetsByCityQuery: string; constructor( @InjectRepository(SmartInfoEntity) private repository: Repository, @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository, @InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(IConfigRepository) configRepository: IConfigRepository, ) { + this.vectorExtension = configRepository.getEnv().database.vectorExtension; this.logger.setContext(SearchRepository.name); this.faceColumns = this.assetFaceRepository.manager.connection .getMetadata(AssetFaceEntity) @@ -61,18 +67,16 @@ export class SearchRepository implements ISearchRepository { { takenAfter: DummyValue.DATE, lensModel: DummyValue.STRING, - ownerId: DummyValue.UUID, withStacked: true, isFavorite: true, - ownerIds: [DummyValue.UUID], + userIds: [DummyValue.UUID], }, ], }) async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); + builder = searchAssetBuilder(builder, options).orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: (pagination.page - 1) * pagination.size, @@ -80,12 +84,33 @@ export class SearchRepository implements ISearchRepository { }); } - private createPersonFilter(builder: SelectQueryBuilder, personIds: string[]) { - return builder - .select(`${builder.alias}."assetId"`) - .where(`${builder.alias}."personId" IN (:...personIds)`, { personIds }) - .groupBy(`${builder.alias}."assetId"`) - .having(`COUNT(DISTINCT ${builder.alias}."personId") = :personCount`, { personCount: personIds.length }); + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + async searchRandom(size: number, options: AssetSearchOptions): Promise { + const builder1 = searchAssetBuilder(this.assetRepository.createQueryBuilder('asset'), options); + const builder2 = builder1.clone(); + + const uuid = randomUUID(); + builder1.andWhere('asset.id > :uuid', { uuid }).orderBy('asset.id').take(size); + builder2.andWhere('asset.id < :uuid', { uuid }).orderBy('asset.id').take(size); + + const [assets1, assets2] = await Promise.all([builder1.getMany(), builder2.getMany()]); + const missingCount = size - assets1.length; + for (let i = 0; i < missingCount && i < assets2.length; i++) { + assets1.push(assets2[i]); + } + + return assets1; } @GenerateSql({ @@ -103,21 +128,12 @@ export class SearchRepository implements ISearchRepository { }) async searchSmart( pagination: SearchPaginationOptions, - { embedding, userIds, personIds, ...options }: SmartSearchOptions, + { embedding, userIds, ...options }: SmartSearchOptions, ): Paginated { let results: PaginationResult = { items: [], hasNextPage: false }; await this.assetRepository.manager.transaction(async (manager) => { let builder = manager.createQueryBuilder(AssetEntity, 'asset'); - - if (personIds?.length) { - const assetFaceBuilder = manager.createQueryBuilder(AssetFaceEntity, 'asset_face'); - const cte = this.createPersonFilter(assetFaceBuilder, personIds); - builder - .addCommonTableExpression(cte, 'asset_face_ids') - .innerJoin('asset_face_ids', 'a', 'a."assetId" = asset.id'); - } - builder = searchAssetBuilder(builder, options); builder .innerJoin('asset.smartSearch', 'search') @@ -322,8 +338,95 @@ export class SearchRepository implements ISearchRepository { return this.smartSearchRepository.clear(); } + @GenerateSql({ params: [[DummyValue.UUID]] }) + async getCountries(userIds: string[]): Promise { + const results = await this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.country', 'country') + .distinctOn(['exif.country']) + .getRawMany<{ country: string }>(); + + return results.map(({ country }) => country).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getStates(userIds: string[], country: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.state', 'state') + .distinctOn(['exif.state']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + const result = await query.getRawMany<{ state: string }>(); + + return result.map(({ state }) => state).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) + async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.city', 'city') + .distinctOn(['exif.city']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + if (state) { + query.andWhere('exif.state = :state', { state }); + } + + const results = await query.getRawMany<{ city: string }>(); + + return results.map(({ city }) => city).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraMakes(userIds: string[], model: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.make', 'make') + .distinctOn(['exif.make']); + + if (model) { + query.andWhere('exif.model = :model', { model }); + } + + const results = await query.getRawMany<{ make: string }>(); + return results.map(({ make }) => make).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraModels(userIds: string[], make: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.model', 'model') + .distinctOn(['exif.model']); + + if (make) { + query.andWhere('exif.make = :make', { make }); + } + + const results = await query.getRawMany<{ model: string }>(); + return results.map(({ model }) => model).filter((item) => item !== ''); + } + private getRuntimeConfig(numResults?: number): string { - if (getVectorExtension() === DatabaseExtension.VECTOR) { + if (this.vectorExtension === DatabaseExtension.VECTOR) { return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall } diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index f74eb7dd0d275..1936ecdb61aba 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -4,7 +4,7 @@ import { exec as execCallback } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import sharp from 'sharp'; -import { resourcePaths } from 'src/constants'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -37,7 +37,10 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => { @Instrumentation() @Injectable() export class ServerInfoRepository implements IServerInfoRepository { - constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + constructor( + @Inject(IConfigRepository) private configRepository: IConfigRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { this.logger.setContext(ServerInfoRepository.name); } @@ -56,6 +59,8 @@ export class ServerInfoRepository implements IServerInfoRepository { } async getBuildVersions(): Promise { + const { nodeVersion, resourcePaths } = this.configRepository.getEnv(); + const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([ maybeFirstLine('node --version'), maybeFirstLine('ffmpeg -version'), @@ -67,7 +72,7 @@ export class ServerInfoRepository implements IServerInfoRepository { .catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`)); return { - nodejs: nodejsOutput || process.env.NODE_VERSION || '', + nodejs: nodejsOutput || nodeVersion || '', exiftool: await exiftool.version(), ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '', libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips, diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index c699047ce1575..b95744998403f 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -40,8 +40,16 @@ export class StorageRepository implements IStorageRepository { return fs.stat(filepath); } - writeFile(filepath: string, buffer: Buffer) { - return fs.writeFile(filepath, buffer); + createFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'wx' }); + } + + createOrOverwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'w' }); + } + + overwriteFile(filepath: string, buffer: Buffer) { + return fs.writeFile(filepath, buffer, { flag: 'r+' }); } rename(source: string, target: string) { @@ -148,7 +156,9 @@ export class StorageRepository implements IStorageRepository { return Promise.resolve([]); } - return glob(this.asGlob(pathsToCrawl), { + const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); + + return glob(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, @@ -164,7 +174,9 @@ export class StorageRepository implements IStorageRepository { return emptyGenerator(); } - const stream = globStream(this.asGlob(pathsToCrawl), { + const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); + + const stream = globStream(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, @@ -198,10 +210,9 @@ export class StorageRepository implements IStorageRepository { return () => watcher.close(); } - private asGlob(pathsToCrawl: string[]): string { - const escapedPaths = pathsToCrawl.map((path) => escapePath(path)); - const base = escapedPaths.length === 1 ? escapedPaths[0] : `{${escapedPaths.join(',')}}`; + private asGlob(pathToCrawl: string): string { + const escapedPath = escapePath(pathToCrawl); const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`; - return `${base}/**/${extensions}`; + return `${escapedPath}/**/${extensions}`; } } diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 9389aeb13b4e3..1a5415b8dbb08 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -1,10 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { TagEntity } from 'src/entities/tag.entity'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { DataSource, In, Repository } from 'typeorm'; +import { DataSource, In, Repository, TreeRepository } from 'typeorm'; @Instrumentation() @Injectable() @@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository { constructor( @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, - ) {} + @InjectRepository(TagEntity) private tree: TreeRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(TagRepository.name); + } get(id: string): Promise { return this.repository.findOne({ where: { id } }); @@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository { }); } + async deleteEmptyTags() { + await this.dataSource.transaction(async (manager) => { + const ids = new Set(); + const tags = await manager.find(TagEntity); + for (const tag of tags) { + const count = await manager + .createQueryBuilder('assets', 'asset') + .innerJoin( + 'asset.tags', + 'asset_tags', + 'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)', + { tagId: tag.id }, + ) + .getCount(); + + if (count === 0) { + this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`); + ids.add(tag.id); + } + } + + if (ids.size > 0) { + await manager.delete(TagEntity, { id: In([...ids]) }); + this.logger.log(`Deleted ${ids.size} empty tags`); + } + }); + } + private async save(partial: Partial): Promise { const { id } = await this.repository.save(partial); return this.repository.findOneOrFail({ where: { id } }); diff --git a/server/src/repositories/trash.repository.ts b/server/src/repositories/trash.repository.ts new file mode 100644 index 0000000000000..d24f4f709afac --- /dev/null +++ b/server/src/repositories/trash.repository.ts @@ -0,0 +1,52 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { AssetStatus } from 'src/enum'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; +import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination'; +import { In, Repository } from 'typeorm'; + +export class TrashRepository implements ITrashRepository { + constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + + async getDeletedIds(pagination: PaginationOptions): Paginated { + const { hasNextPage, items } = await paginatedBuilder( + this.assetRepository + .createQueryBuilder('asset') + .select('asset.id') + .where({ status: AssetStatus.DELETED }) + .withDeleted(), + pagination, + ); + + return { + hasNextPage, + items: items.map((asset) => asset.id), + }; + } + + async restore(userId: string): Promise { + const result = await this.assetRepository.update( + { ownerId: userId, status: AssetStatus.TRASHED }, + { status: AssetStatus.ACTIVE, deletedAt: null }, + ); + + return result.affected || 0; + } + + async empty(userId: string): Promise { + const result = await this.assetRepository.update( + { ownerId: userId, status: AssetStatus.TRASHED }, + { status: AssetStatus.DELETED }, + ); + + return result.affected || 0; + } + + async restoreAll(ids: string[]): Promise { + const result = await this.assetRepository.update( + { id: In(ids), status: AssetStatus.TRASHED }, + { status: AssetStatus.ACTIVE, deletedAt: null }, + ); + return result.affected ?? 0; + } +} diff --git a/server/src/repositories/version-history.repository.ts b/server/src/repositories/version-history.repository.ts new file mode 100644 index 0000000000000..26c638bd769a6 --- /dev/null +++ b/server/src/repositories/version-history.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { VersionHistoryEntity } from 'src/entities/version-history.entity'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +import { Instrumentation } from 'src/utils/instrumentation'; +import { Repository } from 'typeorm'; + +@Instrumentation() +@Injectable() +export class VersionHistoryRepository implements IVersionHistoryRepository { + constructor(@InjectRepository(VersionHistoryEntity) private repository: Repository) {} + + async getAll(): Promise { + return this.repository.find({ order: { createdAt: 'DESC' } }); + } + + async getLatest(): Promise { + const results = await this.repository.find({ order: { createdAt: 'DESC' }, take: 1 }); + return results[0] || null; + } + + create(version: Omit): Promise { + return this.repository.save(version); + } +} diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts new file mode 100644 index 0000000000000..3645e3638af32 --- /dev/null +++ b/server/src/repositories/view-repository.ts @@ -0,0 +1,48 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IViewRepository } from 'src/interfaces/view.interface'; +import { Brackets, Repository } from 'typeorm'; + +export class ViewRepository implements IViewRepository { + constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + + async getUniqueOriginalPaths(userId: string): Promise { + const results = await this.assetRepository + .createQueryBuilder('asset') + .where({ + isVisible: true, + isArchived: false, + ownerId: userId, + }) + .select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath') + .getRawMany(); + + return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, '')); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) + async getAssetsByOriginalPath(userId: string, partialPath: string): Promise { + const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, ''); + const assets = await this.assetRepository + .createQueryBuilder('asset') + .where({ + isVisible: true, + isArchived: false, + ownerId: userId, + }) + .leftJoinAndSelect('asset.exifInfo', 'exifInfo') + .andWhere( + new Brackets((qb) => { + qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere( + 'asset.originalPath NOT LIKE :notLikePath', + { notLikePath: `%${normalizedPath}/%/%` }, + ); + }), + ) + .orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC') + .getMany(); + + return assets; + } +} diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index 30720b6c1fb29..f9a8e6ce47bc6 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -4,20 +4,18 @@ import { IActivityRepository } from 'src/interfaces/activity.interface'; import { ActivityService } from 'src/services/activity.service'; import { activityStub } from 'test/fixtures/activity.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(ActivityService.name, () => { let sut: ActivityService; + let accessMock: IAccessRepositoryMock; let activityMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - activityMock = newActivityRepositoryMock(); - - sut = new ActivityService(accessMock, activityMock); + ({ sut, accessMock, activityMock } = newTestService(ActivityService)); }); it('should work', () => { diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index 1e4034de936fa..4e17baebc32e5 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ActivityCreateDto, ActivityDto, @@ -13,20 +13,14 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { ActivityEntity } from 'src/entities/activity.entity'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; @Injectable() -export class ActivityService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IActivityRepository) private repository: IActivityRepository, - ) {} - +export class ActivityService extends BaseService { async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); - const activities = await this.repository.search({ + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + const activities = await this.activityRepository.search({ userId: dto.userId, albumId: dto.albumId, assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId, @@ -37,12 +31,12 @@ export class ActivityService { } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); - return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) }; + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + return { comments: await this.activityRepository.getStatistics(dto.assetId, dto.albumId) }; } async create(auth: AuthDto, dto: ActivityCreateDto): Promise> { - await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); const common = { userId: auth.user.id, @@ -55,7 +49,7 @@ export class ActivityService { if (dto.type === ReactionType.LIKE) { delete dto.comment; - [activity] = await this.repository.search({ + [activity] = await this.activityRepository.search({ ...common, // `null` will search for an album like assetId: dto.assetId ?? null, @@ -65,7 +59,7 @@ export class ActivityService { } if (!activity) { - activity = await this.repository.create({ + activity = await this.activityRepository.create({ ...common, isLiked: dto.type === ReactionType.LIKE, comment: dto.comment, @@ -76,7 +70,7 @@ export class ActivityService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); - await this.repository.delete(id); + await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); + await this.activityRepository.delete(id); } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 164e823336878..33c8f5dd7f624 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -4,39 +4,27 @@ import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole } from 'src/enum'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AlbumService } from 'src/services/album.service'; import { albumStub } from 'test/fixtures/album.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(AlbumService.name, () => { let sut: AlbumService; + let accessMock: IAccessRepositoryMock; let albumMock: Mocked; - let assetMock: Mocked; + let albumUserMock: Mocked; let eventMock: Mocked; let userMock: Mocked; - let albumUserMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); - eventMock = newEventRepositoryMock(); - userMock = newUserRepositoryMock(); - albumUserMock = newAlbumUserRepositoryMock(); - - sut = new AlbumService(accessMock, albumMock, assetMock, eventMock, userMock, albumUserMock); + ({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService)); }); it('should work', () => { @@ -67,7 +55,6 @@ describe(AlbumService.name, () => { { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); expect(result).toHaveLength(2); @@ -85,7 +72,6 @@ describe(AlbumService.name, () => { endDate: new Date('1970-01-01'), }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id }); expect(result).toHaveLength(1); @@ -98,7 +84,6 @@ describe(AlbumService.name, () => { albumMock.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: true }); expect(result).toHaveLength(1); @@ -111,7 +96,6 @@ describe(AlbumService.name, () => { albumMock.getMetadataForIds.mockResolvedValue([ { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, { shared: false }); expect(result).toHaveLength(1); @@ -130,7 +114,6 @@ describe(AlbumService.name, () => { endDate: new Date('1970-01-01'), }, ]); - albumMock.getInvalidThumbnail.mockResolvedValue([]); const result = await sut.getAll(authStub.admin, {}); @@ -139,48 +122,6 @@ describe(AlbumService.name, () => { expect(albumMock.getOwned).toHaveBeenCalledTimes(1); }); - it('updates the album thumbnail by listing all albums', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]); - albumMock.getMetadataForIds.mockResolvedValue([ - { - albumId: albumStub.oneAssetInvalidThumbnail.id, - assetCount: 1, - startDate: new Date('1970-01-01'), - endDate: new Date('1970-01-01'), - }, - ]); - albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]); - albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail); - assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]); - - const result = await sut.getAll(authStub.admin, {}); - - expect(result).toHaveLength(1); - expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledTimes(1); - }); - - it('removes the thumbnail for an empty album', async () => { - albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]); - albumMock.getMetadataForIds.mockResolvedValue([ - { - albumId: albumStub.emptyWithInvalidThumbnail.id, - assetCount: 1, - startDate: new Date('1970-01-01'), - endDate: new Date('1970-01-01'), - }, - ]); - albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]); - albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail); - assetMock.getFirstAssetForAlbumId.mockResolvedValue(null); - - const result = await sut.getAll(authStub.admin, {}); - - expect(result).toHaveLength(1); - expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1); - expect(albumMock.update).toHaveBeenCalledTimes(1); - }); - describe('create', () => { it('creates album', async () => { albumMock.create.mockResolvedValue(albumStub.empty); @@ -365,6 +306,17 @@ describe(AlbumService.name, () => { expect(albumMock.update).not.toHaveBeenCalled(); }); + it('should throw an error if the userId is the ownerId', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); + await expect( + sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { + albumUsers: [{ userId: userStub.user1.id }], + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(albumMock.update).not.toHaveBeenCalled(); + }); + it('should add valid shared users', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); @@ -474,6 +426,19 @@ describe(AlbumService.name, () => { }); }); + describe('updateUser', () => { + it('should update user role', async () => { + accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); + await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { + role: AlbumUserRole.EDITOR, + }); + expect(albumUserMock.update).toHaveBeenCalledWith( + { albumId: albumStub.sharedWithAdmin.id, userId: userStub.admin.id }, + { role: AlbumUserRole.EDITOR }, + ); + }); + }); + describe('getAlbumInfo', () => { it('should get a shared album', async () => { albumMock.getById.mockResolvedValue(albumStub.oneAsset); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index b59364af9fb6e..a9a678d605091 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { AddUsersDto, AlbumInfoDto, @@ -17,26 +17,13 @@ import { AlbumUserEntity } from 'src/entities/album-user.entity'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; -import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() -export class AlbumService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, - ) {} - +export class AlbumService extends BaseService { async getStatistics(auth: AuthDto): Promise { const [owned, shared, notShared] = await Promise.all([ this.albumRepository.getOwned(auth.user.id), @@ -52,11 +39,7 @@ export class AlbumService { } async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise { - const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); - for (const albumId of invalidAlbumIds) { - const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId); - await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail }); - } + await this.albumRepository.updateThumbnails(); let albums: AlbumEntity[]; if (assetId) { @@ -99,7 +82,7 @@ export class AlbumService { } async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [id] }); await this.albumRepository.updateThumbnails(); const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const album = await this.findOrFail(id, { withAssets }); @@ -123,7 +106,7 @@ export class AlbumService { } } - const allowedAssetIdsSet = await checkAccess(this.access, { + const allowedAssetIdsSet = await checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds || [], @@ -147,7 +130,7 @@ export class AlbumService { } async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: true }); @@ -170,17 +153,17 @@ export class AlbumService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_DELETE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DELETE, ids: [id] }); await this.albumRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await requireAccess(this.access, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); const results = await addAssets( auth, - { access: this.access, bulk: this.albumRepository }, + { access: this.accessRepository, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids }, ); @@ -199,12 +182,12 @@ export class AlbumService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); const results = await removeAssets( auth, - { access: this.access, bulk: this.albumRepository }, + { access: this.accessRepository, bulk: this.albumRepository }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE }, ); @@ -220,7 +203,7 @@ export class AlbumService { } async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); @@ -264,14 +247,14 @@ export class AlbumService { // non-admin can remove themselves if (auth.user.id !== userId) { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); } await this.albumUserRepository.delete({ albumId: id, userId }); } async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 4d13eead575fc..3841ba1be9756 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -5,19 +5,17 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { APIKeyService } from 'src/services/api-key.service'; import { keyStub } from 'test/fixtures/api-key.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(APIKeyService.name, () => { let sut: APIKeyService; - let keyMock: Mocked; + let cryptoMock: Mocked; + let keyMock: Mocked; beforeEach(() => { - cryptoMock = newCryptoRepositoryMock(); - keyMock = newKeyRepositoryMock(); - sut = new APIKeyService(cryptoMock, keyMock); + ({ sut, cryptoMock, keyMock } = newTestService(APIKeyService)); }); describe('create', () => { @@ -48,6 +46,15 @@ describe(APIKeyService.name, () => { expect(cryptoMock.newPassword).toHaveBeenCalled(); expect(cryptoMock.hashSha256).toHaveBeenCalled(); }); + + it('should throw an error if the api key does not have sufficient permissions', async () => { + await expect( + sut.create( + { ...authStub.admin, apiKey: { ...keyStub.admin, permissions: [] } }, + { permissions: [Permission.ASSET_READ] }, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('update', () => { diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 7dd1ed5c268ba..303ca05537781 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -1,27 +1,21 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { APIKeyEntity } from 'src/entities/api-key.entity'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; @Injectable() -export class APIKeyService { - constructor( - @Inject(ICryptoRepository) private crypto: ICryptoRepository, - @Inject(IKeyRepository) private repository: IKeyRepository, - ) {} - +export class APIKeyService extends BaseService { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise { - const secret = this.crypto.newPassword(32); + const secret = this.cryptoRepository.newPassword(32); if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) { throw new BadRequestException('Cannot grant permissions you do not have'); } - const entity = await this.repository.create({ - key: this.crypto.hashSha256(secret), + const entity = await this.keyRepository.create({ + key: this.cryptoRepository.hashSha256(secret), name: dto.name || 'API Key', userId: auth.user.id, permissions: dto.permissions, @@ -31,27 +25,27 @@ export class APIKeyService { } async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise { - const exists = await this.repository.getById(auth.user.id, id); + const exists = await this.keyRepository.getById(auth.user.id, id); if (!exists) { throw new BadRequestException('API Key not found'); } - const key = await this.repository.update(auth.user.id, id, { name: dto.name }); + const key = await this.keyRepository.update(auth.user.id, id, { name: dto.name }); return this.map(key); } async delete(auth: AuthDto, id: string): Promise { - const exists = await this.repository.getById(auth.user.id, id); + const exists = await this.keyRepository.getById(auth.user.id, id); if (!exists) { throw new BadRequestException('API Key not found'); } - await this.repository.delete(auth.user.id, id); + await this.keyRepository.delete(auth.user.id, id); } async getById(auth: AuthDto, id: string): Promise { - const key = await this.repository.getById(auth.user.id, id); + const key = await this.keyRepository.getById(auth.user.id, id); if (!key) { throw new BadRequestException('API Key not found'); } @@ -59,7 +53,7 @@ export class APIKeyService { } async getAll(auth: AuthDto): Promise { - const keys = await this.repository.getByUserId(auth.user.id); + const keys = await this.keyRepository.getByUserId(auth.user.id); return keys.map((key) => this.map(key)); } diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 039dcb9aaeafd..66f8061d3c869 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -2,7 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; -import { ONE_HOUR, resourcePaths } from 'src/constants'; +import { ONE_HOUR } from 'src/constants'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService } from 'src/services/auth.service'; import { JobService } from 'src/services/job.service'; @@ -37,6 +38,7 @@ export class ApiService { private jobService: JobService, private sharedLinkService: SharedLinkService, private versionService: VersionService, + @Inject(IConfigRepository) private configRepository: IConfigRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ApiService.name); @@ -53,6 +55,8 @@ export class ApiService { } ssr(excludePaths: string[]) { + const { resourcePaths } = this.configRepository.getEnv(); + let index = ''; try { index = readFileSync(resourcePaths.web.indexHtml).toString(); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 2f5192d84fcf6..c269739935e01 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -1,28 +1,27 @@ -import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + InternalServerErrorException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { Stats } from 'node:fs'; import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; -import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; +import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetMediaService } from 'src/services/asset-media.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { userStub } from 'test/fixtures/user.stub'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { QueryFailedError } from 'typeorm'; import { Mocked } from 'vitest'; @@ -189,27 +188,22 @@ const copiedAsset = Object.freeze({ describe(AssetMediaService.name, () => { let sut: AssetMediaService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; let jobMock: Mocked; - let loggerMock: Mocked; let storageMock: Mocked; let userMock: Mocked; - let eventMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - jobMock = newJobRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - eventMock = newEventRepositoryMock(); - - sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock); + ({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService)); }); describe('getUploadAssetIdByChecksum', () => { + it('should return if checksum is undefined', async () => { + await expect(sut.getUploadAssetIdByChecksum(authStub.admin)).resolves.toBe(undefined); + }); + it('should handle a non-existent asset', async () => { await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined(); expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1); @@ -311,6 +305,35 @@ describe(AssetMediaService.name, () => { }); describe('uploadAsset', () => { + it('should throw an error if the quota is exceeded', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 42, + }; + + assetMock.create.mockResolvedValue(assetEntity); + + await expect( + sut.uploadAsset( + { ...authStub.admin, user: { ...authStub.admin.user, quotaSizeInBytes: 42, quotaUsageInBytes: 1 } }, + createDto, + file, + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.create).not.toHaveBeenCalled(); + expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(storageMock.utimes).not.toHaveBeenCalledWith( + file.originalPath, + expect.any(Date), + new Date(createDto.fileModifiedAt), + ); + }); + it('should handle a file upload', async () => { const file = { uuid: 'random-uuid', @@ -364,6 +387,31 @@ describe(AssetMediaService.name, () => { expect(userMock.updateUsage).not.toHaveBeenCalled(); }); + it('should throw an error if the duplicate could not be found by checksum', async () => { + const file = { + uuid: 'random-uuid', + originalPath: 'fake_path/asset_1.jpeg', + mimeType: 'image/jpeg', + checksum: Buffer.from('file hash', 'utf8'), + originalName: 'asset_1.jpeg', + size: 0, + }; + const error = new QueryFailedError('', [], new Error('unique key violation')); + (error as any).constraint = ASSET_CHECKSUM_CONSTRAINT; + + assetMock.create.mockRejectedValue(error); + + await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf( + InternalServerErrorException, + ); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.DELETE_FILES, + data: { files: ['fake_path/asset_1.jpeg', undefined] }, + }); + expect(userMock.updateUsage).not.toHaveBeenCalled(); + }); + it('should handle a live photo', async () => { assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); @@ -401,6 +449,23 @@ describe(AssetMediaService.name, () => { expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false }); }); + + it('should handle a sidecar file', async () => { + assetMock.getById.mockResolvedValueOnce(assetStub.image); + assetMock.create.mockResolvedValueOnce(assetStub.image); + + await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({ + status: AssetMediaStatus.CREATED, + id: assetStub.image.id, + }); + + expect(storageMock.utimes).toHaveBeenCalledWith( + fileStub.photoSidecar.originalPath, + expect.any(Date), + new Date(createDto.fileModifiedAt), + ); + expect(assetMock.update).not.toHaveBeenCalled(); + }); }); describe('downloadOriginal', () => { @@ -435,6 +500,170 @@ describe(AssetMediaService.name, () => { }); }); + describe('viewThumbnail', () => { + it('should require asset.view permissions', async () => { + await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + }); + + it('should throw an error if the asset does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should throw an error if the requested thumbnail file does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] }); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should throw an error if the requested preview file does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + files: [ + { + assetId: assetStub.image.id, + createdAt: assetStub.image.fileCreatedAt, + id: '42', + path: '/path/to/preview', + type: AssetFileType.THUMBNAIL, + updatedAt: new Date(), + }, + ], + }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should fall back to preview if the requested thumbnail file does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + files: [ + { + assetId: assetStub.image.id, + createdAt: assetStub.image.fileCreatedAt, + id: '42', + path: '/path/to/preview.jpg', + type: AssetFileType.PREVIEW, + updatedAt: new Date(), + }, + ], + }); + + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: '/path/to/preview.jpg', + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'image/jpeg', + }), + ); + }); + + it('should get preview file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.image.files[0].path, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'image/jpeg', + }), + ); + }); + + it('should get thumbnail file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue({ ...assetStub.image }); + await expect( + sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), + ).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.image.files[1].path, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'application/octet-stream', + }), + ); + }); + }); + + describe('playbackVideo', () => { + it('should require asset.view permissions', async () => { + await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id'])); + }); + + it('should throw an error if the asset does not exist', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(null); + + await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('should throw an error if the asset is not a video', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(assetStub.image); + + await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should return the encoded video path if available', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id])); + assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo); + + await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.hasEncodedVideo.encodedVideoPath!, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'video/mp4', + }), + ); + }); + + it('should fall back to the original path', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); + assetMock.getById.mockResolvedValue(assetStub.video); + + await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( + new ImmichFileResponse({ + path: assetStub.video.originalPath, + cacheControl: CacheControl.PRIVATE_WITH_CACHE, + contentType: 'application/octet-stream', + }), + ); + }); + }); + + describe('checkExistingAssets', () => { + it('should get existing asset ids', async () => { + assetMock.getByDeviceIds.mockResolvedValue(['42']); + await expect( + sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }), + ).resolves.toEqual({ existingIds: ['42'] }); + + expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']); + }); + }); + describe('replaceAsset', () => { it('should error when update photo does not exist', async () => { assetMock.getById.mockResolvedValueOnce(null); @@ -478,7 +707,10 @@ describe(AssetMediaService.name, () => { }), ); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]); + expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + deletedAt: expect.any(Date), + status: AssetStatus.TRASHED, + }); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(storageMock.utimes).toHaveBeenCalledWith( updatedFile.originalPath, @@ -506,7 +738,10 @@ describe(AssetMediaService.name, () => { id: 'copied-asset', }); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']); + expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + deletedAt: expect.any(Date), + status: AssetStatus.TRASHED, + }); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(storageMock.utimes).toHaveBeenCalledWith( updatedFile.originalPath, @@ -532,7 +767,10 @@ describe(AssetMediaService.name, () => { id: 'copied-asset', }); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']); + expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], { + deletedAt: expect.any(Date), + status: AssetStatus.TRASHED, + }); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(storageMock.utimes).toHaveBeenCalledWith( updatedFile.originalPath, @@ -561,7 +799,7 @@ describe(AssetMediaService.name, () => { }); expect(assetMock.create).not.toHaveBeenCalled(); - expect(assetMock.softDeleteAll).not.toHaveBeenCalled(); + expect(assetMock.updateAll).not.toHaveBeenCalled(); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.DELETE_FILES, data: { files: [updatedFile.originalPath, undefined] }, @@ -589,8 +827,52 @@ describe(AssetMediaService.name, () => { }), ).resolves.toEqual({ results: [ - { id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, - { id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE }, + { + id: '1', + assetId: 'asset-1', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + { + id: '2', + assetId: 'asset-2', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + ], + }); + + expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + }); + + it('should return non-duplicates as well', async () => { + const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); + const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex'); + + assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]); + + await expect( + sut.bulkUploadCheck(authStub.admin, { + assets: [ + { id: '1', checksum: file1.toString('hex') }, + { id: '2', checksum: file2.toString('base64') }, + ], + }), + ).resolves.toEqual({ + results: [ + { + id: '1', + assetId: 'asset-1', + action: AssetUploadAction.REJECT, + reason: AssetRejectReason.DUPLICATE, + isTrashed: false, + }, + { + id: '2', + action: AssetUploadAction.ACCEPT, + }, ], }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 9ce2e58d28fad..b320c32a213ae 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -1,13 +1,7 @@ -import { - BadRequestException, - Inject, - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, @@ -27,17 +21,12 @@ import { } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetType, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum'; +import { JobName } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess, requireUploadAccess } from 'src/utils/access'; -import { getAssetFiles } from 'src/utils/asset.util'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; +import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; import { QueryFailedError } from 'typeorm'; @@ -56,19 +45,7 @@ export interface UploadFile { } @Injectable() -export class AssetMediaService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(AssetMediaService.name); - } - +export class AssetMediaService extends BaseService { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { if (!checksum) { return; @@ -148,7 +125,7 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await requireAccess(this.access, { + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPLOAD, // do not need an id here, but the interface requires it @@ -158,20 +135,10 @@ export class AssetMediaService { this.requireQuota(auth, file.size); if (dto.livePhotoVideoId) { - const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId); - if (!motionAsset) { - throw new BadRequestException('Live photo video not found'); - } - if (motionAsset.type !== AssetType.VIDEO) { - throw new BadRequestException('Live photo vide must be a video'); - } - if (motionAsset.ownerId !== auth.user.id) { - throw new BadRequestException('Live photo video does not belong to the user'); - } - if (motionAsset.isVisible) { - await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, auth.user.id, motionAsset.id); - } + await onBeforeLink( + { asset: this.assetRepository, event: this.eventRepository }, + { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, + ); } const asset = await this.create(auth.user.id, dto, file, sidecarFile); @@ -192,7 +159,7 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const asset = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); @@ -203,9 +170,8 @@ export class AssetMediaService { // but the local variable holds the original file data paths. const copiedPhoto = await this.createCopy(asset); // and immediate trash it - await this.assetRepository.softDeleteAll([copiedPhoto.id]); - - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); + await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.TRASHED }); + await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id }); await this.userRepository.updateUsage(auth.user.id, file.size); @@ -216,12 +182,9 @@ export class AssetMediaService { } async downloadOriginal(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); const asset = await this.findOrFail(id); - if (!asset) { - throw new NotFoundException('Asset does not exist'); - } return new ImmichFileResponse({ path: asset.originalPath, @@ -231,7 +194,7 @@ export class AssetMediaService { } async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; @@ -254,12 +217,9 @@ export class AssetMediaService { } async playbackVideo(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); - if (!asset) { - throw new NotFoundException('Asset does not exist'); - } if (asset.type !== AssetType.VIDEO) { throw new BadRequestException('Asset is not a video'); @@ -289,10 +249,10 @@ export class AssetMediaService { async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise { const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum)); const results = await this.assetRepository.getByChecksums(auth.user.id, checksums); - const checksumMap: Record = {}; + const checksumMap: Record = {}; - for (const { id, checksum } of results) { - checksumMap[checksum.toString('hex')] = id; + for (const { id, deletedAt, checksum } of results) { + checksumMap[checksum.toString('hex')] = { id, isTrashed: !!deletedAt }; } return { @@ -301,14 +261,13 @@ export class AssetMediaService { if (duplicate) { return { id, - assetId: duplicate, action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE, + assetId: duplicate.id, + isTrashed: duplicate.isTrashed, }; } - // TODO mime-check - return { id, action: AssetUploadAction.ACCEPT, @@ -439,7 +398,6 @@ export class AssetMediaService { livePhotoVideoId: dto.livePhotoVideoId, originalFileName: file.originalName, sidecarPath: sidecarFile?.originalPath, - isOffline: dto.isOffline ?? false, }); if (sidecarFile) { diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 3ac7aa1c718f7..9063df9dc2a82 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,12 +1,12 @@ import { BadRequestException } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetType } from 'src/enum'; +import { AssetStatus, AssetType } from 'src/enum'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IStackRepository } from 'src/interfaces/stack.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -17,15 +17,8 @@ import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, vitest } from 'vitest'; const stats: AssetStats = { @@ -43,15 +36,15 @@ const statResponse: AssetStatsResponseDto = { describe(AssetService.name, () => { let sut: AssetService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let jobMock: Mocked; - let userMock: Mocked; let eventMock: Mocked; + let jobMock: Mocked; + let partnerMock: Mocked; let stackMock: Mocked; let systemMock: Mocked; - let partnerMock: Mocked; - let loggerMock: Mocked; + let userMock: Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -64,27 +57,8 @@ describe(AssetService.name, () => { }; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - userMock = newUserRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - stackMock = newStackRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new AssetService( - accessMock, - assetMock, - jobMock, - systemMock, - userMock, - eventMock, - partnerMock, - stackMock, - loggerMock, - ); + ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } = + newTestService(AssetService)); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); @@ -155,6 +129,28 @@ describe(AssetService.name, () => { }); }); + describe('getRandom', () => { + it('should get own random assets', async () => { + assetMock.getRandom.mockResolvedValue([assetStub.image]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + }); + + it('should not include partner assets if not in timeline', async () => { + assetMock.getRandom.mockResolvedValue([assetStub.image]); + partnerMock.getAll.mockResolvedValue([{ ...partnerStub.user1ToAdmin1, inTimeline: false }]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.getRandom).toHaveBeenCalledWith([authStub.admin.user.id], 1); + }); + + it('should include partner assets if in timeline', async () => { + assetMock.getRandom.mockResolvedValue([assetStub.image]); + partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + await sut.getRandom(authStub.admin, 1); + expect(assetMock.getRandom).toHaveBeenCalledWith([userStub.admin.id, userStub.user1.id], 1); + }); + }); + describe('get', () => { it('should allow owner access', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); @@ -176,6 +172,23 @@ describe(AssetService.name, () => { ); }); + it('should strip metadata for shared link if exif is disabled', async () => { + accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id])); + assetMock.getById.mockResolvedValue(assetStub.image); + + const result = await sut.get( + { ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, + assetStub.image.id, + ); + + expect(result).toEqual(expect.objectContaining({ hasMetadata: false })); + expect(result).not.toHaveProperty('exifInfo'); + expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLink?.id, + new Set([assetStub.image.id]), + ); + }); + it('should allow partner sharing access', async () => { accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id])); assetMock.getById.mockResolvedValue(assetStub.image); @@ -206,6 +219,11 @@ describe(AssetService.name, () => { expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); expect(assetMock.getById).not.toHaveBeenCalled(); }); + + it('should throw an error if the asset could not be found', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); + await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('update', () => { @@ -236,6 +254,132 @@ describe(AssetService.name, () => { await sut.update(authStub.admin, 'asset-1', { rating: 3 }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 }); }); + + it('should fail linking a live video if the motion part could not be found', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + assetId: assetStub.livePhotoMotionAsset.id, + userId: userStub.admin.id, + }); + }); + + it('should fail linking a live video if the motion part is not a video', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + assetId: assetStub.livePhotoMotionAsset.id, + userId: userStub.admin.id, + }); + }); + + it('should fail linking a live video if the motion part has a different owner', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + expect(assetMock.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(eventMock.emit).not.toHaveBeenCalledWith('asset.show', { + assetId: assetStub.livePhotoMotionAsset.id, + userId: userStub.admin.id, + }); + }); + + it('should link a live video', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValueOnce({ + ...assetStub.livePhotoMotionAsset, + ownerId: authStub.admin.user.id, + isVisible: true, + }); + assetMock.getById.mockResolvedValueOnce(assetStub.image); + + await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false }); + expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + assetId: assetStub.livePhotoMotionAsset.id, + userId: userStub.admin.id, + }); + expect(assetMock.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: assetStub.livePhotoMotionAsset.id, + }); + }); + + it('should throw an error if asset could not be found after update', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('should unlink a live video', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); + assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); + assetMock.getById.mockResolvedValueOnce(assetStub.image); + + await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); + + expect(assetMock.update).toHaveBeenCalledWith({ + id: assetStub.livePhotoStillAsset.id, + livePhotoVideoId: null, + }); + expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true }); + expect(eventMock.emit).toHaveBeenCalledWith('asset.show', { + assetId: assetStub.livePhotoMotionAsset.id, + userId: userStub.admin.id, + }); + }); + + it('should fail unlinking a live video if the asset could not be found', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); + assetMock.getById.mockResolvedValue(null); + + await expect( + sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + expect(eventMock.emit).not.toHaveBeenCalledWith(); + }); }); describe('updateAll', () => { @@ -269,10 +413,10 @@ describe(AssetService.name, () => { await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true }); - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: 'asset1', deleteOnDisk: true } }, - { name: JobName.ASSET_DELETION, data: { id: 'asset2', deleteOnDisk: true } }, - ]); + expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', { + assetIds: ['asset1', 'asset2'], + userId: 'user-id', + }); }); it('should soft delete a batch of assets', async () => { @@ -280,11 +424,50 @@ describe(AssetService.name, () => { await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); - expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { + deletedAt: expect.any(Date), + status: AssetStatus.TRASHED, + }); expect(jobMock.queue.mock.calls).toEqual([]); }); }); + describe('handleAssetDeletionCheck', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should immediately queue assets for deletion if trash is disabled', async () => { + assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + systemMock.get.mockResolvedValue({ trash: { enabled: false } }); + + await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { trashedBefore: new Date() }); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, + ]); + }); + + it('should queue assets for deletion after trash duration', async () => { + assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + systemMock.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); + + await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.getAll).toHaveBeenCalledWith(expect.anything(), { + trashedBefore: DateTime.now().minus({ days: 7 }).toJSDate(), + }); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, + ]); + }); + }); + describe('handleAssetDeletion', () => { it('should remove faces', async () => { const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] }; @@ -324,6 +507,17 @@ describe(AssetService.name, () => { }); }); + it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => { + assetMock.getById.mockResolvedValue({ + ...assetStub.primaryImage, + stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) }, + } as AssetEntity); + + await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true }); + + expect(stackMock.delete).toHaveBeenCalledWith('stack-1'); + }); + it('should delete a live photo', async () => { assetMock.getById.mockResolvedValue(assetStub.livePhotoStillAsset); assetMock.getLivePhotoCount.mockResolvedValue(0); @@ -380,9 +574,21 @@ describe(AssetService.name, () => { await sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000); }); + + it('should fail if asset could not be found', async () => { + await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( + JobStatus.FAILED, + ); + }); }); describe('run', () => { + it('should run the refresh faces job', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES }); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); + }); + it('should run the refresh metadata job', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); @@ -392,7 +598,7 @@ describe(AssetService.name, () => { it('should run the refresh thumbnails job', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }]); + expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index bfd3a0c4d26b5..5fef742f5a2eb 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,7 +1,6 @@ -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, MemoryLaneResponseDto, @@ -20,46 +19,21 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { AssetStatus, Permission } from 'src/enum'; import { IAssetDeleteJob, - IJobRepository, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobItem, JobName, JobStatus, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; -import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util'; +import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; -export class AssetService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IStackRepository) private stackRepository: IStackRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(AssetService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - +export class AssetService extends BaseService { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { const partnerIds = await getMyPartnerIds({ userId: auth.user.id, @@ -98,7 +72,12 @@ export class AssetService { } async getRandom(auth: AuthDto, count: number): Promise { - const assets = await this.assetRepository.getRandom(auth.user.id, count); + const partnerIds = await getMyPartnerIds({ + userId: auth.user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }); + const assets = await this.assetRepository.getRandom([auth.user.id, ...partnerIds], count); return assets.map((a) => mapAsset(a, { auth })); } @@ -107,15 +86,15 @@ export class AssetService { } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [id] }); const asset = await this.assetRepository.getById( id, { exifInfo: true, - tags: true, sharedLinks: true, smartInfo: true, + tags: true, owner: true, faces: { person: true, @@ -156,12 +135,29 @@ export class AssetService { } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; + const repos = { asset: this.assetRepository, event: this.eventRepository }; + + let previousMotion: AssetEntity | null = null; + if (rest.livePhotoVideoId) { + await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); + } else if (rest.livePhotoVideoId === null) { + const asset = await this.findOrFail(id); + if (asset.livePhotoVideoId) { + previousMotion = await onBeforeUnlink(repos, { livePhotoVideoId: asset.livePhotoVideoId }); + } + } + await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); await this.assetRepository.update({ id, ...rest }); + + if (previousMotion) { + await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id }); + } + const asset = await this.assetRepository.getById(id, { exifInfo: true, owner: true, @@ -172,15 +168,17 @@ export class AssetService { }, files: true, }); + if (!asset) { throw new BadRequestException('Asset not found'); } + return mapAsset(asset, { auth }); } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids }); for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); @@ -190,7 +188,7 @@ export class AssetService { } async handleAssetDeletionCheck(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const trashedDays = config.trash.enabled ? config.trash.days : 0; const trashedBefore = DateTime.now() .minus(Duration.fromObject({ days: trashedDays })) @@ -249,7 +247,8 @@ export class AssetService { if (!asset.libraryId) { await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0)); } - this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id); + + await this.eventRepository.emit('asset.delete', { assetId: id, userId: asset.ownerId }); // delete the motion if it is not used by another asset if (asset.livePhotoVideoId) { @@ -276,35 +275,33 @@ export class AssetService { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { const { ids, force } = dto; - await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids }); - - if (force) { - await this.jobRepository.queueAll( - ids.map((id) => ({ - name: JobName.ASSET_DELETION, - data: { id, deleteOnDisk: true }, - })), - ); - } else { - await this.assetRepository.softDeleteAll(ids); - this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids); - } + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids }); + await this.assetRepository.updateAll(ids, { + deletedAt: new Date(), + status: force ? AssetStatus.DELETED : AssetStatus.TRASHED, + }); + await this.eventRepository.emit(force ? 'assets.delete' : 'assets.trash', { assetIds: ids, userId: auth.user.id }); } async run(auth: AuthDto, dto: AssetJobsDto) { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const jobs: JobItem[] = []; for (const id of dto.assetIds) { switch (dto.name) { + case AssetJobName.REFRESH_FACES: { + jobs.push({ name: JobName.FACE_DETECTION, data: { id } }); + break; + } + case AssetJobName.REFRESH_METADATA: { jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } }); break; } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id } }); + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } }); break; } @@ -318,6 +315,14 @@ export class AssetService { await this.jobRepository.queueAll(jobs); } + private async findOrFail(id: string) { + const asset = await this.assetRepository.getById(id); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + return asset; + } + private async updateMetadata(dto: ISidecarWriteJob) { const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto; const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index ef685f4a87755..c7a51565afa5c 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -1,46 +1,28 @@ -import { DatabaseAction, EntityType } from 'src/enum'; +import { BadRequestException } from '@nestjs/common'; +import { FileReportItemDto } from 'src/dtos/audit.dto'; +import { AssetFileType, AssetPathType, DatabaseAction, EntityType, PersonPathType, UserPathType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuditService } from 'src/services/audit.service'; import { auditStub } from 'test/fixtures/audit.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(AuditService.name, () => { let sut: AuditService; - let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; let auditMock: Mocked; + let assetMock: Mocked; let cryptoMock: Mocked; let personMock: Mocked; - let storageMock: Mocked; let userMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - auditMock = newAuditRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock, loggerMock); + ({ sut, auditMock, assetMock, cryptoMock, personMock, userMock } = newTestService(AuditService)); }); it('should work', () => { @@ -87,4 +69,148 @@ describe(AuditService.name, () => { }); }); }); + + describe('getChecksums', () => { + it('should fail if the file is not in the immich path', async () => { + await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); + + expect(cryptoMock.hashFile).not.toHaveBeenCalled(); + }); + + it('should get checksum for valid file', async () => { + await expect(sut.getChecksums({ filenames: ['./upload/my-file.jpg'] })).resolves.toEqual([ + { filename: './upload/my-file.jpg', checksum: expect.any(String) }, + ]); + + expect(cryptoMock.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg'); + }); + }); + + describe('fixItems', () => { + it('should fail if the file is not in the immich path', async () => { + await expect( + sut.fixItems([ + { entityId: 'my-id', pathType: AssetPathType.ORIGINAL, pathValue: 'foo/bar' } as FileReportItemDto, + ]), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.upsertFile).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update encoded video path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.ENCODED_VIDEO, + pathValue: './upload/my-video.mp4', + } as FileReportItemDto, + ]); + + expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' }); + expect(assetMock.upsertFile).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update preview path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.PREVIEW, + pathValue: './upload/my-preview.png', + } as FileReportItemDto, + ]); + + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'my-id', + type: AssetFileType.PREVIEW, + path: './upload/my-preview.png', + }); + expect(assetMock.update).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update thumbnail path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.THUMBNAIL, + pathValue: './upload/my-thumbnail.webp', + } as FileReportItemDto, + ]); + + expect(assetMock.upsertFile).toHaveBeenCalledWith({ + assetId: 'my-id', + type: AssetFileType.THUMBNAIL, + path: './upload/my-thumbnail.webp', + }); + expect(assetMock.update).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update original path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.ORIGINAL, + pathValue: './upload/my-original.png', + } as FileReportItemDto, + ]); + + expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' }); + expect(assetMock.upsertFile).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update sidecar path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: AssetPathType.SIDECAR, + pathValue: './upload/my-sidecar.xmp', + } as FileReportItemDto, + ]); + + expect(assetMock.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' }); + expect(assetMock.upsertFile).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update face path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: PersonPathType.FACE, + pathValue: './upload/my-face.jpg', + } as FileReportItemDto, + ]); + + expect(personMock.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' }); + expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.upsertFile).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); + }); + + it('should update profile path', async () => { + await sut.fixItems([ + { + entityId: 'my-id', + pathType: UserPathType.PROFILE, + pathValue: './upload/my-profile-pic.jpg', + } as FileReportItemDto, + ]); + + expect(userMock.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' }); + expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.upsertFile).not.toHaveBeenCalled(); + expect(personMock.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 4f292f7cc1300..60f8d6fa81617 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -1,8 +1,8 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AuditDeletesDto, AuditDeletesResponseDto, @@ -12,46 +12,33 @@ import { PathEntityType, } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; -import { AssetFileType, DatabaseAction, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IAuditRepository } from 'src/interfaces/audit.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { + AssetFileType, + AssetPathType, + DatabaseAction, + Permission, + PersonPathType, + StorageFolder, + UserPathType, +} from 'src/enum'; import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class AuditService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(IAuditRepository) private repository: IAuditRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(AuditService.name); - } - +export class AuditService extends BaseService { async handleCleanup(): Promise { - await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); + await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); return JobStatus.SUCCESS; } async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise { const userId = dto.userId || auth.user.id; - await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); - const audits = await this.repository.getAfter(dto.after, { + const audits = await this.auditRepository.getAfter(dto.after, { userIds: [userId], entityType: dto.entityType, action: DatabaseAction.DELETE, @@ -115,7 +102,7 @@ export class AuditService { } case PersonPathType.FACE: { - await this.personRepository.update([{ id, thumbnailPath: pathValue }]); + await this.personRepository.update({ id, thumbnailPath: pathValue }); break; } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index acc2d3459ccd1..f45affe0425a4 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,13 +1,12 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; -import { Issuer, generators } from 'openid-client'; -import { AuthType } from 'src/constants'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AuthType, Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IOAuthRepository } from 'src/interfaces/oauth.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -19,15 +18,8 @@ import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; -import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; -import { Mock, Mocked, vitest } from 'vitest'; +import { newTestService } from 'test/utils'; +import { Mocked } from 'vitest'; // const token = Buffer.from('my-api-key', 'utf8').toString('base64'); @@ -57,54 +49,36 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; + let cryptoMock: Mocked; let eventMock: Mocked; - let userMock: Mocked; - let loggerMock: Mocked; - let systemMock: Mocked; - let sessionMock: Mocked; - let shareMock: Mocked; let keyMock: Mocked; - - let callbackMock: Mock; - let userinfoMock: Mock; + let oauthMock: Mocked; + let sessionMock: Mocked; + let sharedLinkMock: Mocked; + let systemMock: Mocked; + let userMock: Mocked; beforeEach(() => { - callbackMock = vitest.fn().mockReturnValue({ access_token: 'access-token' }); - userinfoMock = vitest.fn().mockResolvedValue({ sub, email }); - - vitest.spyOn(generators, 'state').mockReturnValue('state'); - vitest.spyOn(Issuer, 'discover').mockResolvedValue({ - id_token_signing_alg_values_supported: ['RS256'], - Client: vitest.fn().mockResolvedValue({ - issuer: { - metadata: { - end_session_endpoint: 'http://end-session-endpoint', - }, - }, - authorizationUrl: vitest.fn().mockReturnValue('http://authorization-url'), - callbackParams: vitest.fn().mockReturnValue({ state: 'state' }), - callback: callbackMock, - userinfo: userinfoMock, - }), - } as any); - - cryptoMock = newCryptoRepositoryMock(); - eventMock = newEventRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - sessionMock = newSessionRepositoryMock(); - shareMock = newSharedLinkRepositoryMock(); - keyMock = newKeyRepositoryMock(); - - sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); + ({ sut, cryptoMock, eventMock, keyMock, oauthMock, sessionMock, sharedLinkMock, systemMock, userMock } = + newTestService(AuthService)); + + oauthMock.authorize.mockResolvedValue('access-token'); + oauthMock.getProfile.mockResolvedValue({ sub, email }); + oauthMock.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint'); }); it('should be defined', () => { expect(sut).toBeDefined(); }); + describe('onBootstrap', () => { + it('should init the repo', () => { + sut.onBootstrap(); + expect(oauthMock.init).toHaveBeenCalled(); + }); + }); + describe('login', () => { it('should throw an error if password login is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.disabled); @@ -283,7 +257,7 @@ describe('AuthService', () => { describe('validate - shared key', () => { it('should not accept a non-existent key', async () => { - shareMock.getByKey.mockResolvedValue(null); + sharedLinkMock.getByKey.mockResolvedValue(null); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -294,7 +268,7 @@ describe('AuthService', () => { }); it('should not accept an expired key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); await expect( sut.authenticate({ headers: { 'x-immich-share-key': 'key' }, @@ -304,8 +278,19 @@ describe('AuthService', () => { ).rejects.toBeInstanceOf(UnauthorizedException); }); + it('should not accept a key on a non-shared route', async () => { + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + await expect( + sut.authenticate({ + headers: { 'x-immich-share-key': 'key' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + it('should not accept a key without a user', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); userMock.get.mockResolvedValue(null); await expect( sut.authenticate({ @@ -317,7 +302,7 @@ describe('AuthService', () => { }); it('should accept a base64url key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); await expect( sut.authenticate({ @@ -329,11 +314,11 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); it('should accept a hex key', async () => { - shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid); userMock.get.mockResolvedValue(userStub.admin); await expect( sut.authenticate({ @@ -345,7 +330,7 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); }); @@ -413,6 +398,17 @@ describe('AuthService', () => { expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); + it('should throw an error if api key has insufficient permissions', async () => { + keyMock.getKey.mockResolvedValue({ ...keyStub.admin, permissions: [] }); + await expect( + sut.authenticate({ + headers: { 'x-api-key': 'auth_token' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: Permission.ASSET_READ }, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + it('should return an auth dto', async () => { keyMock.getKey.mockResolvedValue(keyStub.admin); await expect( @@ -438,6 +434,20 @@ describe('AuthService', () => { }); }); + describe('authorize', () => { + it('should fail if oauth is disabled', async () => { + systemMock.get.mockResolvedValue({ oauth: { enabled: false } }); + await expect(sut.authorize({ redirectUri: 'https://demo.immich.app' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('should authorize the user', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride); + await sut.authorize({ redirectUri: 'https://demo.immich.app' }); + }); + }); + describe('callback', () => { it('should throw an error if OAuth is not enabled', async () => { await expect(sut.callback({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException); @@ -495,6 +505,22 @@ describe('AuthService', () => { expect(userMock.create).toHaveBeenCalledTimes(1); }); + it('should throw an error if user should be auto registered but the email claim does not exist', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.enabled); + userMock.getByEmail.mockResolvedValue(null); + userMock.getAdmin.mockResolvedValue(userStub.user1); + userMock.create.mockResolvedValue(userStub.user1); + sessionMock.create.mockResolvedValue(sessionStub.valid); + oauthMock.getProfile.mockResolvedValue({ sub, email: undefined }); + + await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(userMock.getByEmail).not.toHaveBeenCalled(); + expect(userMock.create).not.toHaveBeenCalled(); + }); + for (const url of [ 'app.immich:/', 'app.immich://', @@ -509,7 +535,7 @@ describe('AuthService', () => { sessionMock.create.mockResolvedValue(sessionStub.valid); await sut.callback({ url }, loginDetails); - expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); + expect(oauthMock.getProfile).toHaveBeenCalledWith(expect.objectContaining({}), url, 'http://mobile-redirect'); }); } @@ -531,7 +557,7 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: 'abc' }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, @@ -545,7 +571,7 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: -5 }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, @@ -559,7 +585,7 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: 0 }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, @@ -579,7 +605,7 @@ describe('AuthService', () => { userMock.getByEmail.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1); - userinfoMock.mockResolvedValue({ sub, email, immich_quota: 5 }); + oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 6eaf755d0eb49..1fbc8b69ffad9 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -1,20 +1,10 @@ -import { - BadRequestException, - ForbiddenException, - Inject, - Injectable, - InternalServerErrorException, - UnauthorizedException, -} from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { isNumber, isString } from 'class-validator'; import cookieParser from 'cookie'; import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; -import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; -import { SystemConfig } from 'src/config'; -import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { UserCore } from 'src/cores/user.core'; +import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { OnEvent } from 'src/decorators'; import { AuthDto, ChangePasswordDto, @@ -31,17 +21,12 @@ import { } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; -import { Permission } from 'src/enum'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { AuthType, Permission } from 'src/enum'; +import { OAuthProfile } from 'src/interfaces/oauth.interface'; +import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; +import { createUser } from 'src/utils/user'; export interface LoginDetails { isSecure: boolean; @@ -50,8 +35,6 @@ export interface LoginDetails { deviceOS: string; } -type OAuthProfile = UserinfoResponse; - interface ClaimOptions { key: string; default: T; @@ -70,29 +53,14 @@ export type ValidateRequest = { }; @Injectable() -export class AuthService { - private configCore: SystemConfigCore; - private userCore: UserCore; - - constructor( - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ISessionRepository) private sessionRepository: ISessionRepository, - @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, - @Inject(IKeyRepository) private keyRepository: IKeyRepository, - ) { - this.logger.setContext(AuthService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - this.userCore = UserCore.create(cryptoRepository, userRepository); - - custom.setHttpOptionsDefaults({ timeout: 30_000 }); +export class AuthService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap() { + this.oauthRepository.init(); } async login(dto: LoginCredentialDto, details: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.passwordLogin.enabled) { throw new UnauthorizedException('Password login has been disabled'); } @@ -150,13 +118,16 @@ export class AuthService { throw new BadRequestException('The server already has an admin'); } - const admin = await this.userCore.createUser({ - isAdmin: true, - email: dto.email, - name: dto.name, - password: dto.password, - storageLabel: 'admin', - }); + const admin = await createUser( + { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, + { + isAdmin: true, + email: dto.email, + name: dto.name, + password: dto.password, + storageLabel: 'admin', + }, + ); return mapUserAdmin(admin); } @@ -211,25 +182,20 @@ export class AuthService { } async authorize(dto: OAuthConfigDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); - if (!config.oauth.enabled) { + const { oauth } = await this.getConfig({ withCache: false }); + + if (!oauth.enabled) { throw new BadRequestException('OAuth is not enabled'); } - const client = await this.getOAuthClient(config); - const url = client.authorizationUrl({ - redirect_uri: this.normalize(config, dto.redirectUri), - scope: config.oauth.scope, - state: generators.state(), - }); - + const url = await this.oauthRepository.authorize(oauth, dto.redirectUri); return { url }; } async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { - const config = await this.configCore.getConfig({ withCache: false }); - const profile = await this.getOAuthProfile(config, dto.url); - const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = config.oauth; + const { oauth } = await this.getConfig({ withCache: false }); + const profile = await this.oauthRepository.getProfile(oauth, dto.url, this.normalize(oauth, dto.url.split('?')[0])); + const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); let user = await this.userRepository.getByOAuthId(profile.sub); @@ -271,21 +237,28 @@ export class AuthService { }); const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`; - user = await this.userCore.createUser({ - name: userName, - email: profile.email, - oauthId: profile.sub, - quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, - storageLabel: storageLabel || null, - }); + user = await createUser( + { userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, + { + name: userName, + email: profile.email, + oauthId: profile.sub, + quotaSizeInBytes: storageQuota * HumanReadableSize.GiB || null, + storageLabel: storageLabel || null, + }, + ); } return this.createLoginResponse(user, loginDetails); } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { - const config = await this.configCore.getConfig({ withCache: false }); - const { sub: oauthId } = await this.getOAuthProfile(config, dto.url); + const { oauth } = await this.getConfig({ withCache: false }); + const { sub: oauthId } = await this.oauthRepository.getProfile( + oauth, + dto.url, + this.normalize(oauth, dto.url.split('?')[0]), + ); const duplicate = await this.userRepository.getByOAuthId(oauthId); if (duplicate && duplicate.id !== auth.user.id) { this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`); @@ -306,65 +279,12 @@ export class AuthService { return LOGIN_URL; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); if (!config.oauth.enabled) { return LOGIN_URL; } - const client = await this.getOAuthClient(config); - return client.issuer.metadata.end_session_endpoint || LOGIN_URL; - } - - private async getOAuthProfile(config: SystemConfig, url: string): Promise { - const redirectUri = this.normalize(config, url.split('?')[0]); - const client = await this.getOAuthClient(config); - const params = client.callbackParams(url); - try { - const tokens = await client.callback(redirectUri, params, { state: params.state }); - return client.userinfo(tokens.access_token || ''); - } catch (error: Error | any) { - if (error.message.includes('unexpected JWT alg received')) { - this.logger.warn( - [ - 'Algorithm mismatch. Make sure the signing algorithm is set correctly in the OAuth settings.', - 'Or, that you have specified a signing key in your OAuth provider.', - ].join(' '), - ); - } - - throw error; - } - } - - private async getOAuthClient(config: SystemConfig) { - const { enabled, clientId, clientSecret, issuerUrl, signingAlgorithm, profileSigningAlgorithm } = config.oauth; - - if (!enabled) { - throw new BadRequestException('OAuth2 is not enabled'); - } - - try { - const issuer = await Issuer.discover(issuerUrl); - return new issuer.Client({ - client_id: clientId, - client_secret: clientSecret, - response_types: ['code'], - userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm, - id_token_signed_response_alg: signingAlgorithm, - }); - } catch (error: any | AggregateError) { - this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors); - throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error }); - } - } - - private normalize(config: SystemConfig, redirectUri: string) { - const isMobile = redirectUri.startsWith('app.immich:/'); - const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth; - if (isMobile && mobileOverrideEnabled && mobileRedirectUri) { - return mobileRedirectUri; - } - return redirectUri; + return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL; } private getBearerToken(headers: IncomingHttpHeaders): string | null { @@ -448,4 +368,15 @@ export class AuthService { const value = profile[options.key as keyof OAuthProfile]; return options.isValid(value) ? (value as T) : options.default; } + + private normalize( + { mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean }, + redirectUri: string, + ) { + const isMobile = redirectUri.startsWith('app.immich:/'); + if (isMobile && mobileOverrideEnabled && mobileRedirectUri) { + return mobileRedirectUri; + } + return redirectUri; + } } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts new file mode 100644 index 0000000000000..e98f88ade1462 --- /dev/null +++ b/server/src/services/base.service.ts @@ -0,0 +1,113 @@ +import { Inject } from '@nestjs/common'; +import { SystemConfig } from 'src/config'; +import { StorageCore } from 'src/cores/storage.core'; +import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IActivityRepository } from 'src/interfaces/activity.interface'; +import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { IKeyRepository } from 'src/interfaces/api-key.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IAuditRepository } from 'src/interfaces/audit.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IDatabaseRepository } from 'src/interfaces/database.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IJobRepository } from 'src/interfaces/job.interface'; +import { ILibraryRepository } from 'src/interfaces/library.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; +import { IMapRepository } from 'src/interfaces/map.interface'; +import { IMediaRepository } from 'src/interfaces/media.interface'; +import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { IMetadataRepository } from 'src/interfaces/metadata.interface'; +import { IMetricRepository } from 'src/interfaces/metric.interface'; +import { IMoveRepository } from 'src/interfaces/move.interface'; +import { INotificationRepository } from 'src/interfaces/notification.interface'; +import { IOAuthRepository } from 'src/interfaces/oauth.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { IPersonRepository } from 'src/interfaces/person.interface'; +import { ISearchRepository } from 'src/interfaces/search.interface'; +import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { ISessionRepository } from 'src/interfaces/session.interface'; +import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; +import { IStackRepository } from 'src/interfaces/stack.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ITagRepository } from 'src/interfaces/tag.interface'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +import { IViewRepository } from 'src/interfaces/view.interface'; +import { getConfig, updateConfig } from 'src/utils/config'; + +export class BaseService { + protected storageCore: StorageCore; + + constructor( + @Inject(ILoggerRepository) protected logger: ILoggerRepository, + @Inject(IAccessRepository) protected accessRepository: IAccessRepository, + @Inject(IActivityRepository) protected activityRepository: IActivityRepository, + @Inject(IAuditRepository) protected auditRepository: IAuditRepository, + @Inject(IAlbumRepository) protected albumRepository: IAlbumRepository, + @Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository, + @Inject(IAssetRepository) protected assetRepository: IAssetRepository, + @Inject(IConfigRepository) protected configRepository: IConfigRepository, + @Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository, + @Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository, + @Inject(IEventRepository) protected eventRepository: IEventRepository, + @Inject(IJobRepository) protected jobRepository: IJobRepository, + @Inject(IKeyRepository) protected keyRepository: IKeyRepository, + @Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository, + @Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository, + @Inject(IMapRepository) protected mapRepository: IMapRepository, + @Inject(IMediaRepository) protected mediaRepository: IMediaRepository, + @Inject(IMemoryRepository) protected memoryRepository: IMemoryRepository, + @Inject(IMetadataRepository) protected metadataRepository: IMetadataRepository, + @Inject(IMetricRepository) protected metricRepository: IMetricRepository, + @Inject(IMoveRepository) protected moveRepository: IMoveRepository, + @Inject(INotificationRepository) protected notificationRepository: INotificationRepository, + @Inject(IOAuthRepository) protected oauthRepository: IOAuthRepository, + @Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository, + @Inject(IPersonRepository) protected personRepository: IPersonRepository, + @Inject(ISearchRepository) protected searchRepository: ISearchRepository, + @Inject(IServerInfoRepository) protected serverInfoRepository: IServerInfoRepository, + @Inject(ISessionRepository) protected sessionRepository: ISessionRepository, + @Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository, + @Inject(IStackRepository) protected stackRepository: IStackRepository, + @Inject(IStorageRepository) protected storageRepository: IStorageRepository, + @Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository, + @Inject(ITagRepository) protected tagRepository: ITagRepository, + @Inject(ITrashRepository) protected trashRepository: ITrashRepository, + @Inject(IUserRepository) protected userRepository: IUserRepository, + @Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository, + @Inject(IViewRepository) protected viewRepository: IViewRepository, + ) { + this.logger.setContext(this.constructor.name); + this.storageCore = StorageCore.create( + assetRepository, + configRepository, + cryptoRepository, + moveRepository, + personRepository, + storageRepository, + systemMetadataRepository, + this.logger, + ); + } + + private get repos() { + return { + configRepo: this.configRepository, + metadataRepo: this.systemMetadataRepository, + logger: this.logger, + }; + } + + getConfig(options: { withCache: boolean }) { + return getConfig(this.repos, options); + } + + updateConfig(newConfig: SystemConfig) { + return updateConfig(this.repos, newConfig); + } +} diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index e52c6486647da..3ccc122eceeb0 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,30 +1,16 @@ -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { CliService } from 'src/services/cli.service'; import { userStub } from 'test/fixtures/user.stub'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, describe, it } from 'vitest'; describe(CliService.name, () => { let sut: CliService; let userMock: Mocked; - let cryptoMock: Mocked; - let systemMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - cryptoMock = newCryptoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new CliService(cryptoMock, systemMock, userMock, loggerMock); + ({ sut, userMock } = newTestService(CliService)); }); describe('resetAdminPassword', () => { diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 1c25c306b6843..18a79108c4468 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,26 +1,10 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class CliService { - private configCore: SystemConfigCore; - - constructor( - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(CliService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - +export class CliService extends BaseService { async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); return users.map((user) => mapUserAdmin(user)); @@ -42,26 +26,26 @@ export class CliService { } async disablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enablePasswordLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.passwordLogin.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async disableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = false; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } async enableOAuthLogin(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); config.oauth.enabled = true; - await this.configCore.updateConfig(config); + await this.updateConfig(config); } } diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index c63428560e03c..0bf851f41da9e 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,12 +1,20 @@ -import { DatabaseExtension, EXTENSION_NAMES, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { + DatabaseExtension, + EXTENSION_NAMES, + IDatabaseRepository, + VectorExtension, +} from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DatabaseService } from 'src/services/database.service'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(DatabaseService.name, () => { let sut: DatabaseService; + + let configMock: Mocked; let databaseMock: Mocked; let loggerMock: Mocked; let extensionRange: string; @@ -16,9 +24,7 @@ describe(DatabaseService.name, () => { let versionAboveRange: string; beforeEach(() => { - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new DatabaseService(databaseMock, loggerMock); + ({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService)); extensionRange = '0.2.x'; databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); @@ -33,11 +39,6 @@ describe(DatabaseService.name, () => { }); }); - afterEach(() => { - delete process.env.DB_SKIP_MIGRATIONS; - delete process.env.DB_VECTOR_EXTENSION; - }); - it('should work', () => { expect(sut).toBeDefined(); }); @@ -50,12 +51,24 @@ describe(DatabaseService.name, () => { expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); - describe.each([ + describe.each(>[ { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, ])('should work with $extensionName', ({ extension, extensionName }) => { beforeEach(() => { - process.env.DB_VECTOR_EXTENSION = extensionName; + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: false, + vectorExtension: extension, + }, + }), + ); }); it(`should start up successfully with ${extension}`, async () => { @@ -236,18 +249,42 @@ describe(DatabaseService.name, () => { expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); expect(loggerMock.fatal).not.toHaveBeenCalled(); }); + }); - it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - process.env.DB_SKIP_MIGRATIONS = 'true'; + it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTORS, + }, + }), + ); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvector extension could not be created`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTOR, + }, + }), + ); databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index a5280ff28be23..363266c6aef6a 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,17 +1,15 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; -import { getVectorExtension } from 'src/database.config'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { DatabaseExtension, DatabaseLock, EXTENSION_NAMES, - IDatabaseRepository, VectorExtension, VectorIndex, } from 'src/interfaces/database.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { BaseService } from 'src/services/base.service'; type CreateFailedArgs = { name: string; extension: string; otherName: string }; type UpdateFailedArgs = { name: string; extension: string; availableVersion: string }; @@ -63,17 +61,10 @@ const messages = { const RETRY_DURATION = Duration.fromObject({ seconds: 5 }); @Injectable() -export class DatabaseService { +export class DatabaseService extends BaseService { private reconnection?: NodeJS.Timeout; - constructor( - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(DatabaseService.name); - } - - @OnEmit({ event: 'app.bootstrap', priority: -200 }) + @OnEvent({ name: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); const current = semver.coerce(version); @@ -85,7 +76,8 @@ export class DatabaseService { } await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => { - const extension = getVectorExtension(); + const envData = this.configRepository.getEnv(); + const extension = envData.database.vectorExtension; const name = EXTENSION_NAMES[extension]; const extensionRange = this.databaseRepository.getExtensionVersionRange(extension); @@ -116,7 +108,8 @@ export class DatabaseService { await this.checkReindexing(); - if (process.env.DB_SKIP_MIGRATIONS !== 'true') { + const { database } = this.configRepository.getEnv(); + if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); } }); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 14fa7bab48f48..632d15738480c 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -7,10 +7,8 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { DownloadService } from 'src/services/download.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Readable } from 'typeorm/platform/PlatformTools.js'; import { Mocked, vitest } from 'vitest'; @@ -36,15 +34,54 @@ describe(DownloadService.name, () => { }); beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - storageMock = newStorageRepositoryMock(); - - sut = new DownloadService(accessMock, assetMock, loggerMock, storageMock); + ({ sut, accessMock, assetMock, loggerMock, storageMock } = newTestService(DownloadService)); }); describe('downloadArchive', () => { + it('should skip asset ids that could not be found', async () => { + const archiveMock = { + addFile: vitest.fn(), + finalize: vitest.fn(), + stream: new Readable(), + }; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + assetMock.getByIds.mockResolvedValue([{ ...assetStub.noResizePath, id: 'asset-1' }]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(1); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + }); + + it('should log a warning if the original path could not be resolved', async () => { + const archiveMock = { + addFile: vitest.fn(), + finalize: vitest.fn(), + stream: new Readable(), + }; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + storageMock.realpath.mockRejectedValue(new Error('Could not read file')); + assetMock.getByIds.mockResolvedValue([ + { ...assetStub.noResizePath, id: 'asset-1' }, + { ...assetStub.noWebpPath, id: 'asset-2' }, + ]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(loggerMock.warn).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); + }); + it('should download an archive', async () => { const archiveMock = { addFile: vitest.fn(), diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 988b859ff882f..d8ad67044e054 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { parse } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { AssetIdsDto } from 'src/dtos/asset.dto'; @@ -6,26 +6,15 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface'; +import { ImmichReadStream } from 'src/interfaces/storage.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class DownloadService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - ) { - this.logger.setContext(DownloadService.name); - } - +export class DownloadService extends BaseService { async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; const archives: DownloadArchiveInfo[] = []; @@ -73,7 +62,7 @@ export class DownloadService { } async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); @@ -116,20 +105,20 @@ export class DownloadService { if (dto.assetIds) { const assetIds = dto.assetIds; - await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true }); return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); } if (dto.albumId) { const albumId = dto.albumId; - await requireAccess(this.access, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); } if (dto.userId) { const userId = dto.userId; - await requireAccess(this.access, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), ); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index eada2fffcf39a..095d53dde6570 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,5 +1,4 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; @@ -7,40 +6,38 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { DuplicateService } from 'src/services/duplicate.service'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { authStub } from 'test/fixtures/auth.stub'; +import { newTestService } from 'test/utils'; import { Mocked, beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: DuplicateService; + let assetMock: Mocked; - let systemMock: Mocked; - let searchMock: Mocked; - let loggerMock: Mocked; - let cryptoMock: Mocked; let jobMock: Mocked; + let loggerMock: Mocked; + let searchMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - searchMock = newSearchRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - - sut = new DuplicateService(systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock); + ({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('getDuplicates', () => { + it('should get duplicates', async () => { + assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]); + await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ + { duplicateId: assetStub.hasDupe.duplicateId, assets: [expect.objectContaining({ id: assetStub.hasDupe.id })] }, + ]); + }); + }); + describe('handleQueueSearchDuplicates', () => { beforeEach(() => { systemMock.get.mockResolvedValue({ diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 35a1a7325bb2f..e76b80b04391c 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,42 +1,18 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { Injectable } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { - IBaseJob, - IEntityJob, - IJobRepository, - JOBS_ASSET_PAGINATION_SIZE, - JobName, - JobStatus, -} from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { AssetDuplicateResult } from 'src/interfaces/search.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class DuplicateService { - private configCore: SystemConfigCore; - - constructor( - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - ) { - this.logger.setContext(DuplicateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } - +export class DuplicateService extends BaseService { async getDuplicates(auth: AuthDto): Promise { const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); @@ -44,7 +20,7 @@ export class DuplicateService { } async handleQueueSearchDuplicates({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -65,7 +41,7 @@ export class DuplicateService { } async handleSearchDuplicates({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.SKIPPED; } diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 1c810facb453a..0353deb39b873 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -1,8 +1,7 @@ import { BadRequestException } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults } from 'src/config'; +import { ImmichWorker } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobCommand, @@ -12,19 +11,10 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMetricRepository } from 'src/interfaces/metric.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { JobService } from 'src/services/job.service'; import { assetStub } from 'test/fixtures/asset.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, vitest } from 'vitest'; const makeMockHandlers = (status: JobStatus) => { @@ -38,28 +28,30 @@ const makeMockHandlers = (status: JobStatus) => { describe(JobService.name, () => { let sut: JobService; let assetMock: Mocked; - let eventMock: Mocked; let jobMock: Mocked; - let personMock: Mocked; - let metricMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - personMock = newPersonRepositoryMock(); - metricMock = newMetricRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new JobService(assetMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock); + ({ sut, assetMock, jobMock, systemMock } = newTestService(JobService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should update concurrency', () => { + sut.onBootstrap(ImmichWorker.MICROSERVICES); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); + + expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); + expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + }); + }); + describe('handleNightlyJobs', () => { it('should run the scheduled jobs', async () => { await sut.handleNightlyJobs(); @@ -239,36 +231,6 @@ describe(JobService.name, () => { expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); }); - it('should subscribe to config changes', async () => { - await sut.init(makeMockHandlers(JobStatus.FAILED)); - - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 10 }, - [QueueName.SMART_SEARCH]: { concurrency: 10 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 10 }, - [QueueName.FACE_DETECTION]: { concurrency: 10 }, - [QueueName.SEARCH]: { concurrency: 10 }, - [QueueName.SIDECAR]: { concurrency: 10 }, - [QueueName.LIBRARY]: { concurrency: 10 }, - [QueueName.MIGRATION]: { concurrency: 10 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 10 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 10 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, - }, - } as SystemConfig); - - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.FACE_DETECTION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.MIGRATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.THUMBNAIL_GENERATION, 10); - expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.VIDEO_CONVERSION, 10); - }); - const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ { item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } }, @@ -288,7 +250,7 @@ describe(JobService.name, () => { }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GENERATE_PREVIEW], + jobs: [JobName.GENERATE_THUMBNAILS], }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, @@ -299,28 +261,16 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1' } }, - jobs: [JobName.GENERATE_THUMBNAIL, JobName.GENERATE_THUMBHASH], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + jobs: [], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-1', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { - item: { name: JobName.GENERATE_PREVIEW, data: { id: 'asset-live-image', source: 'upload' } }, - jobs: [ - JobName.GENERATE_THUMBNAIL, - JobName.GENERATE_THUMBHASH, - JobName.SMART_SEARCH, - JobName.FACE_DETECTION, - JobName.VIDEO_CONVERSION, - ], + item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-live-image', source: 'upload' } }, + jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], }, { item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, @@ -338,11 +288,11 @@ describe(JobService.name, () => { for (const { item, jobs } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { - if (item.name === JobName.GENERATE_PREVIEW && item.data.source === 'upload') { + if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') { if (item.data.id === 'asset-live-image') { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]); } else { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]); } } @@ -361,7 +311,7 @@ describe(JobService.name, () => { } }); - it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { + it(`should not queue any jobs when ${item.name} fails`, async () => { await sut.init(makeMockHandlers(JobStatus.FAILED)); await jobMock.addHandler.mock.calls[0][2](item); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index aa84ef4f40957..971509447f262 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,14 +1,12 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; -import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; +import { AssetType, ImmichWorker, ManualJobName } from 'src/enum'; +import { ArgOf } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, - IJobRepository, JobCommand, JobHandler, JobItem, @@ -17,26 +15,56 @@ import { QueueCleanType, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMetricRepository } from 'src/interfaces/metric.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; + +const asJobItem = (dto: JobCreateDto): JobItem => { + switch (dto.name) { + case ManualJobName.TAG_CLEANUP: { + return { name: JobName.TAG_CLEANUP }; + } + + case ManualJobName.PERSON_CLEANUP: { + return { name: JobName.PERSON_CLEANUP }; + } + + case ManualJobName.USER_CLEANUP: { + return { name: JobName.USER_DELETE_CHECK }; + } + + default: { + throw new BadRequestException('Invalid job name'); + } + } +}; @Injectable() -export class JobService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(IMetricRepository) private metricRepository: IMetricRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(JobService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); +export class JobService extends BaseService { + private isMicroservices = false; + + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap(app: ArgOf<'app.bootstrap'>) { + this.isMicroservices = app === ImmichWorker.MICROSERVICES; + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.isMicroservices) { + return; + } + + this.logger.debug(`Updating queue concurrency settings`); + for (const queueName of Object.values(QueueName)) { + let concurrency = 1; + if (this.isConcurrentQueue(queueName)) { + concurrency = config.job[queueName].concurrency; + } + this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); + this.jobRepository.setConcurrency(queueName, concurrency); + } + } + + async create(dto: JobCreateDto): Promise { + await this.jobRepository.queue(asJobItem(dto)); } async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise { @@ -140,7 +168,7 @@ export class JobService { } case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); + return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, data: { force } }); } default: { @@ -150,7 +178,7 @@ export class JobService { } async init(jobHandlers: Record) { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); for (const queueName of Object.values(QueueName)) { let concurrency = 1; @@ -162,11 +190,16 @@ export class JobService { this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise => { const { name, data } = item; + const handler = jobHandlers[name]; + if (!handler) { + this.logger.warn(`Skipping unknown job: "${name}"`); + return; + } + const queueMetric = `immich.queues.${snakeCase(queueName)}.active`; this.metricRepository.jobs.addToGauge(queueMetric, 1); try { - const handler = jobHandlers[name]; const status = await handler(data); const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`; this.metricRepository.jobs.addToCounter(jobMetric, 1); @@ -180,18 +213,6 @@ export class JobService { } }); } - - this.configCore.config$.subscribe((config) => { - this.logger.debug(`Updating queue concurrency settings`); - for (const queueName of Object.values(QueueName)) { - let concurrency = 1; - if (this.isConcurrentQueue(queueName)) { - concurrency = config.job[queueName].concurrency; - } - this.logger.debug(`Setting ${queueName} concurrency to ${concurrency}`); - this.jobRepository.setConcurrency(queueName, concurrency); - } - }); } private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { @@ -231,13 +252,14 @@ export class JobService { name: JobName.METADATA_EXTRACTION, data: { id: item.data.id, source: 'sidecar-write' }, }); + break; } case JobName.METADATA_EXTRACTION: { if (item.data.source === 'sidecar-write') { const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); if (asset) { - this.eventRepository.clientSend(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset)); + this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset)); } } await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data }); @@ -251,7 +273,7 @@ export class JobService { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GENERATE_PREVIEW, data: item.data }); + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); } break; } @@ -260,45 +282,38 @@ export class JobService { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { - this.eventRepository.clientSend(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id); + this.eventRepository.clientSend('on_person_thumbnail', person.ownerId, person.id); } break; } - case JobName.GENERATE_PREVIEW: { + case JobName.GENERATE_THUMBNAILS: { + if (!item.data.notify && item.data.source !== 'upload') { + break; + } + + const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); + if (!asset) { + this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`); + break; + } + const jobs: JobItem[] = [ - { name: JobName.GENERATE_THUMBNAIL, data: item.data }, - { name: JobName.GENERATE_THUMBHASH, data: item.data }, + { name: JobName.SMART_SEARCH, data: item.data }, + { name: JobName.FACE_DETECTION, data: item.data }, ]; - if (item.data.source === 'upload') { - jobs.push({ name: JobName.SMART_SEARCH, data: item.data }, { name: JobName.FACE_DETECTION, data: item.data }); - - const [asset] = await this.assetRepository.getByIds([item.data.id]); - if (asset) { - if (asset.type === AssetType.VIDEO) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); - } else if (asset.livePhotoVideoId) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); - } - } + if (asset.type === AssetType.VIDEO) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); + } else if (asset.livePhotoVideoId) { + jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } }); } await this.jobRepository.queueAll(jobs); - break; - } - - case JobName.GENERATE_THUMBNAIL: { - if (item.data.source !== 'upload') { - break; + if (asset.isVisible) { + this.eventRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); } - const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]); - - // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients - if (asset && asset.isVisible) { - this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset)); - } break; } @@ -310,7 +325,7 @@ export class JobService { } case JobName.USER_DELETION: { - this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id); + this.eventRepository.clientBroadcast('on_user_delete', item.data.id); break; } } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 2d4e1d5776bfe..e8e276a0e25a2 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,24 +1,20 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { defaults, SystemConfig } from 'src/config'; import { mapLibrary } from 'src/dtos/library.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AssetType } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, JobName, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { LibraryService } from 'src/services/library.service'; @@ -27,48 +23,26 @@ import { authStub } from 'test/fixtures/auth.stub'; import { libraryStub } from 'test/fixtures/library.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, vitest } from 'vitest'; +async function* mockWalk() { + yield await Promise.resolve(['/data/user1/photo.jpg']); +} + describe(LibraryService.name, () => { let sut: LibraryService; let assetMock: Mocked; - let systemMock: Mocked; - let cryptoMock: Mocked; + let databaseMock: Mocked; let jobMock: Mocked; let libraryMock: Mocked; let storageMock: Mocked; - let databaseMock: Mocked; - let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - systemMock = newSystemMetadataRepositoryMock(); - libraryMock = newLibraryRepositoryMock(); - assetMock = newAssetRepositoryMock(); - jobMock = newJobRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - storageMock = newStorageRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new LibraryService( - assetMock, - systemMock, - cryptoMock, - jobMock, - libraryMock, - storageMock, - databaseMock, - loggerMock, - ); + ({ sut, assetMock, databaseMock, jobMock, libraryMock, storageMock, systemMock } = newTestService(LibraryService)); databaseMock.tryLock.mockResolvedValue(true); }); @@ -78,22 +52,26 @@ describe(LibraryService.name, () => { }); describe('onBootstrapEvent', () => { - it('should init cron job and subscribe to config changes', async () => { + it('should init cron job and handle config changes', async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryScan); await sut.onBootstrap(); - expect(systemMock.get).toHaveBeenCalled(); + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); - SystemConfigCore.create(newSystemMetadataRepositoryMock(false), newLoggerRepositoryMock()).config$.next({ - library: { - scan: { - enabled: true, - cronExpression: '0 1 * * *', + await sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + library: { + scan: { + enabled: true, + cronExpression: '0 1 * * *', + }, + watch: { enabled: false }, }, - watch: { enabled: false }, - }, - } as SystemConfig); + } as SystemConfig, + }); expect(jobMock.updateCronJob).toHaveBeenCalledWith('libraryScan', '0 1 * * *', true); }); @@ -163,102 +141,29 @@ describe(LibraryService.name, () => { describe('handleQueueAssetRefresh', () => { it('should queue refresh of a new asset', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); + storageMock.walk.mockImplementation(mockWalk); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibrary1.id, ownerId: libraryStub.externalLibrary1.owner.id, assetPath: '/data/user1/photo.jpg', - force: false, - }, - }, - ]); - }); - - it('should queue offline check of existing online assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - storageMock.walk.mockImplementation(async function* generator() {}); - assetMock.getWith.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { - id: assetStub.external.id, - importPaths: libraryStub.externalLibrary1.importPaths, - exclusionPatterns: [], }, }, ]); }); it("should fail when library can't be found", async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(null); - await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - }); - - it('should force queue new assets', async () => { - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }; - - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - // eslint-disable-next-line @typescript-eslint/require-await - storageMock.walk.mockImplementation(async function* generator() { - yield ['/data/user1/photo.jpg']; - }); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - - await sut.handleQueueAssetRefresh(mockLibraryJob); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { - name: JobName.LIBRARY_SCAN_ASSET, - data: { - id: libraryStub.externalLibrary1.id, - ownerId: libraryStub.externalLibrary1.owner.id, - assetPath: '/data/user1/photo.jpg', - force: true, - }, - }, - ]); + await expect(sut.handleQueueSyncFiles({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); it('should ignore import paths that do not exist', async () => { @@ -276,16 +181,9 @@ describe(LibraryService.name, () => { assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - const mockLibraryJob: ILibraryRefreshJob = { - id: libraryStub.externalLibraryWithImportPaths1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, - }; - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - assetMock.getExternalLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); - await sut.handleQueueAssetRefresh(mockLibraryJob); + await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); expect(storageMock.walk).toHaveBeenCalledWith({ pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]], @@ -296,51 +194,68 @@ describe(LibraryService.name, () => { }); }); - describe('handleOfflineCheck', () => { - it('should skip missing assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { - id: assetStub.external.id, - importPaths: ['/'], - exclusionPatterns: [], - }; + describe('handleQueueRemoveDeleted', () => { + it('should queue online check of existing assets', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.walk.mockImplementation(async function* generator() {}); + assetMock.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false }); - assetMock.getById.mockResolvedValue(null); + await sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id }); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.LIBRARY_SYNC_ASSET, + data: { + id: assetStub.external.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: [], + }, + }, + ]); + }); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); + it("should fail when library can't be found", async () => { + libraryMock.get.mockResolvedValue(null); - expect(assetMock.update).not.toHaveBeenCalled(); + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SKIPPED); }); + }); - it('should do nothing with already-offline assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + describe('handleSyncAsset', () => { + it('should skip missing assets', async () => { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; - assetMock.getById.mockResolvedValue(assetStub.offline); + assetMock.getById.mockResolvedValue(null); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.remove).not.toHaveBeenCalled(); }); it('should offline assets no longer on disk', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should offline assets matching an exclusion pattern', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: ['**/user1/**'], @@ -348,13 +263,15 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should set assets outside of import paths as offline', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/data/user2'], exclusionPatterns: [], @@ -363,28 +280,74 @@ describe(LibraryService.name, () => { assetMock.getById.mockResolvedValue(assetStub.external); storageMock.checkFileExists.mockResolvedValue(true); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + isOffline: true, + deletedAt: expect.any(Date), + }); }); it('should do nothing with online assets', async () => { - const mockAssetJob: ILibraryOfflineJob = { + const mockAssetJob: ILibraryAssetJob = { id: assetStub.external.id, importPaths: ['/'], exclusionPatterns: [], }; assetMock.getById.mockResolvedValue(assetStub.external); - storageMock.checkFileExists.mockResolvedValue(true); + storageMock.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleOfflineCheck(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); - expect(assetMock.update).not.toHaveBeenCalled(); + expect(assetMock.updateAll).not.toHaveBeenCalled(); + }); + + it('should un-trash an asset previously marked as offline', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + assetMock.getById.mockResolvedValue(assetStub.trashedOffline); + storageMock.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.trashedOffline.id], { + deletedAt: null, + fileCreatedAt: assetStub.trashedOffline.fileModifiedAt, + fileModifiedAt: assetStub.trashedOffline.fileModifiedAt, + isOffline: false, + originalFileName: 'path.jpg', + }); }); }); - describe('handleAssetRefresh', () => { + it('should update file when mtime has changed', async () => { + const mockAssetJob: ILibraryAssetJob = { + id: assetStub.external.id, + importPaths: ['/'], + exclusionPatterns: [], + }; + + const newMTime = new Date(); + assetMock.getById.mockResolvedValue(assetStub.external); + storageMock.stat.mockResolvedValue({ mtime: newMTime } as Stats); + + await expect(sut.handleSyncAsset(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.external.id], { + fileModifiedAt: newMTime, + fileCreatedAt: newMTime, + isOffline: false, + originalFileName: 'photo.jpg', + deletedAt: null, + }); + }); + + describe('handleSyncFile', () => { let mockUser: UserEntity; beforeEach(() => { @@ -397,42 +360,18 @@ describe(LibraryService.name, () => { } as Stats); }); - it('should reject an unknown file extension', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should reject an unknown file type', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: mockUser.id, - assetPath: '/data/user1/file.xyz', - force: false, - }; - - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); - }); - - it('should add a new image', async () => { + it('should import a new asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -467,19 +406,19 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new image with sidecar', async () => { + it('should import a new asset with sidecar', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); storageMock.checkFileExists.mockResolvedValue(true); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -514,18 +453,18 @@ describe(LibraryService.name, () => { ]); }); - it('should add a new video', async () => { + it('should import a new video', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/video.mp4', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.video); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); expect(assetMock.create.mock.calls).toEqual([ [ @@ -557,40 +496,30 @@ describe(LibraryService.name, () => { }, }, ], - [ - { - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.video.id, - }, - }, - ], ]); }); - it('should not add an image to a soft deleted library', async () => { + it('should not import an asset to a soft deleted library', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); expect(assetMock.create.mock.calls).toEqual([]); }); - it('should not import an asset when mtime matches db asset', async () => { + it('should not refresh a file whose mtime matches existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: assetStub.hasFileExtension.originalPath, - force: false, }; storageMock.stat.mockResolvedValue({ @@ -601,190 +530,52 @@ describe(LibraryService.name, () => { assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should import an asset when mtime differs from db asset', async () => { + it('should skip existing asset', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.image.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.image.id, - }, - }); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); }); - it('should import an asset that is missing a file extension', async () => { - // This tests for the case where the file extension is missing from the asset path. - // This happened in previous versions of Immich + it('should not refresh an asset trashed by user', async () => { const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: mockUser.id, - assetPath: assetStub.missingFileExtension.originalPath, - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.missingFileExtension); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith( - [assetStub.missingFileExtension.id], - expect.objectContaining({ originalFileName: 'photo.jpg' }), - ); - }); - - it('should set a missing asset to offline', async () => { - storageMock.stat.mockRejectedValue(new Error('Path not found')); - - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, + assetPath: assetStub.hasFileExtension.originalPath, }; - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.trashed); - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - it('should online a previously-offline asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.offline.id, - ownerId: mockUser.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); - assetMock.create.mockResolvedValue(assetStub.offline); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.METADATA_EXTRACTION, - data: { - id: assetStub.offline.id, - source: 'upload', - }, - }); - - expect(jobMock.queue).not.toHaveBeenCalledWith({ - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.offline.id, - }, - }); - }); - - it('should do nothing when mtime matches existing asset', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.image.ownerId, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); - assetMock.create.mockResolvedValue(assetStub.image); - - expect(assetMock.update).not.toHaveBeenCalled(); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - }); - - it('should refresh an existing asset if forced', async () => { - const mockLibraryJob: ILibraryFileJob = { - id: assetStub.image.id, - ownerId: assetStub.hasFileExtension.ownerId, - assetPath: assetStub.hasFileExtension.originalPath, - force: true, - }; - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.hasFileExtension); - assetMock.create.mockResolvedValue(assetStub.hasFileExtension); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasFileExtension.id], { - fileCreatedAt: new Date('2023-01-01'), - fileModifiedAt: new Date('2023-01-01'), - originalFileName: assetStub.hasFileExtension.originalFileName, - }); - }); - - it('should refresh an existing asset with modified mtime', async () => { - const filemtime = new Date(); - filemtime.setSeconds(assetStub.image.fileModifiedAt.getSeconds() + 10); - - const mockLibraryJob: ILibraryFileJob = { - id: libraryStub.externalLibrary1.id, - ownerId: userStub.admin.id, - assetPath: '/data/user1/photo.jpg', - force: false, - }; - - storageMock.stat.mockResolvedValue({ - size: 100, - mtime: filemtime, - ctime: new Date('2023-01-01'), - } as Stats); - - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); - assetMock.create.mockResolvedValue(assetStub.image); - - await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); - - expect(assetMock.create).toHaveBeenCalled(); - const createdAsset = assetMock.create.mock.calls[0][0]; - - expect(createdAsset.fileModifiedAt).toEqual(filemtime); - }); - - it('should throw error when asset does not exist', async () => { + it('should throw BadRequestException when asset does not exist', async () => { storageMock.stat.mockRejectedValue(new Error("ENOENT, no such file or directory '/data/user1/photo.jpg'")); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, ownerId: userStub.admin.id, assetPath: '/data/user1/photo.jpg', - force: false, }; assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.image); - await expect(sut.handleAssetRefresh(mockLibraryJob)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); }); }); @@ -857,7 +648,6 @@ describe(LibraryService.name, () => { describe('getStatistics', () => { it('should return library statistics', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); await expect(sut.getStatistics(libraryStub.externalLibrary1.id)).resolves.toEqual({ photos: 10, @@ -892,7 +682,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: expect.any(String), importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -917,7 +707,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: 'My Awesome Library', importPaths: [], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -947,7 +737,7 @@ describe(LibraryService.name, () => { expect.objectContaining({ name: expect.any(String), importPaths: ['/data/images', '/data/videos'], - exclusionPatterns: [], + exclusionPatterns: expect.any(Array), }), ); }); @@ -1092,12 +882,11 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); @@ -1114,30 +903,16 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { id: libraryStub.externalLibraryWithImportPaths1.id, assetPath: '/foo/photo.jpg', ownerId: libraryStub.externalLibraryWithImportPaths1.owner.id, - force: false, }, }, ]); }); - it('should handle a file unlink event', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); - assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); - storageMock.watch.mockImplementation( - makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), - ); - - await sut.watchAll(); - - expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true }); - }); - it('should handle an error event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); @@ -1232,72 +1007,23 @@ describe(LibraryService.name, () => { }); describe('queueScan', () => { - it('should queue a library scan of external library', async () => { + it('should queue a library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(libraryStub.externalLibrary1.id, {}); + await sut.queueScan(libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: false, }, }, ], - ]); - }); - - it('should queue a library scan of all modified assets', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ [ { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, - }, - }, - ], - ]); - }); - - it('should queue a forced library scan', async () => { - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - - await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_SCAN, - data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, - }, - }, - ], - ]); - }); - }); - - describe('queueEmptyTrash', () => { - it('should queue the trash job', async () => { - await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); - - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.LIBRARY_REMOVE_OFFLINE, + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: libraryStub.externalLibrary1.id, }, @@ -1311,7 +1037,7 @@ describe(LibraryService.name, () => { it('should queue the refresh job', async () => { libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueSyncAll()).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1323,52 +1049,36 @@ describe(LibraryService.name, () => { ]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: true, - refreshAllFiles: false, }, }, ]); }); + }); - it('should queue the force refresh job', async () => { - libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); - - await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS); + describe('handleQueueAssetOfflineCheck', () => { + it('should queue removal jobs', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); + assetMock.getById.mockResolvedValue(assetStub.image1); - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_CLEANUP, - data: {}, - }); + await expect(sut.handleQueueSyncAssets({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_SYNC_ASSET, data: { - id: libraryStub.externalLibrary1.id, - refreshModifiedFiles: false, - refreshAllFiles: true, + id: assetStub.image1.id, + importPaths: libraryStub.externalLibrary1.importPaths, + exclusionPatterns: libraryStub.externalLibrary1.exclusionPatterns, }, }, ]); }); }); - describe('handleRemoveOfflineFiles', () => { - it('should queue trash deletion jobs', async () => { - assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); - assetMock.getById.mockResolvedValue(assetStub.image1); - - await expect(sut.handleRemoveOffline({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); - - expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image1.id, deleteOnDisk: false } }, - ]); - }); - }); - describe('validate', () => { it('should validate directory', async () => { storageMock.stat.mockResolvedValue({ diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 2aa0df402a373..84a8a277f5618 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,71 +1,47 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; -import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { CreateLibraryDto, LibraryResponseDto, LibraryStatsResponseDto, - ScanLibraryDto, + mapLibrary, UpdateLibraryDto, ValidateLibraryDto, ValidateLibraryImportPathResponseDto, ValidateLibraryResponseDto, - mapLibrary, } from 'src/dtos/library.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } from 'src/enum'; -import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { - IBaseJob, IEntityJob, - IJobRepository, + ILibraryAssetJob, ILibraryFileJob, - ILibraryOfflineJob, - ILibraryRefreshJob, - JOBS_LIBRARY_PAGINATION_SIZE, JobName, + JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, } from 'src/interfaces/job.interface'; -import { ILibraryRepository } from 'src/interfaces/library.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { mimeTypes } from 'src/utils/mime-types'; import { handlePromiseError } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { validateCronExpression } from 'src/validation'; @Injectable() -export class LibraryService { - private configCore: SystemConfigCore; +export class LibraryService extends BaseService { private watchLibraries = false; private watchLock = false; private watchers: Record Promise> = {}; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILibraryRepository) private repository: ILibraryRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(LibraryService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const { watch, scan } = config.library; @@ -78,30 +54,31 @@ export class LibraryService { this.jobRepository.addCronJob( 'libraryScan', scan.cronExpression, - () => - handlePromiseError( - this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force: false } }), - this.logger, - ), + () => handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ALL }), this.logger), scan.enabled, ); if (this.watchLibraries) { await this.watchAll(); } + } - this.configCore.config$.subscribe(({ library }) => { - this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); + @OnEvent({ name: 'config.update', server: true }) + async onConfigUpdate({ newConfig: { library }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.watchLock) { + return; + } - if (library.watch.enabled !== this.watchLibraries) { - // Watch configuration changed, update accordingly - this.watchLibraries = library.watch.enabled; - handlePromiseError(this.watchLibraries ? this.watchAll() : this.unwatchAll(), this.logger); - } - }); + this.jobRepository.updateCronJob('libraryScan', library.scan.cronExpression, library.scan.enabled); + + if (library.watch.enabled !== this.watchLibraries) { + // Watch configuration changed, update accordingly + this.watchLibraries = library.watch.enabled; + await (this.watchLibraries ? this.watchAll() : this.unwatchAll()); + } } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { const { scan } = newConfig.library; if (!validateCronExpression(scan.cronExpression)) { @@ -143,7 +120,13 @@ export class LibraryService { const handler = async () => { this.logger.debug(`File add event received for ${path} in library ${library.id}}`); if (matcher(path)) { - await this.scanAssets(library.id, [path], library.ownerId, false); + const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); + if (asset) { + await this.syncAssets(library, [asset.id]); + } + if (matcher(path)) { + await this.syncFiles(library, [path]); + } } }; return handlePromiseError(handler(), this.logger); @@ -151,9 +134,13 @@ export class LibraryService { onChange: (path) => { const handler = async () => { this.logger.debug(`Detected file change for ${path} in library ${library.id}`); + const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); + if (asset) { + await this.syncAssets(library, [asset.id]); + } if (matcher(path)) { // Note: if the changed file was not previously imported, it will be imported now. - await this.scanAssets(library.id, [path], library.ownerId, false); + await this.syncFiles(library, [path]); } }; return handlePromiseError(handler(), this.logger); @@ -162,8 +149,8 @@ export class LibraryService { const handler = async () => { this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`); const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path); - if (asset && matcher(path)) { - await this.assetRepository.update({ id: asset.id, isOffline: true }); + if (asset) { + await this.syncAssets(library, [asset.id]); } }; return handlePromiseError(handler(), this.logger); @@ -187,7 +174,7 @@ export class LibraryService { } } - @OnEmit({ event: 'app.shutdown' }) + @OnEvent({ name: 'app.shutdown' }) async onShutdown() { await this.unwatchAll(); } @@ -207,16 +194,16 @@ export class LibraryService { return false; } - const libraries = await this.repository.getAll(false); + const libraries = await this.libraryRepository.getAll(false); for (const library of libraries) { await this.watch(library.id); } } async getStatistics(id: string): Promise { - const statistics = await this.repository.getStatistics(id); + const statistics = await this.libraryRepository.getStatistics(id); if (!statistics) { - throw new BadRequestException('Library not found'); + throw new BadRequestException(`Library ${id} not found`); } return statistics; } @@ -227,13 +214,13 @@ export class LibraryService { } async getAll(): Promise { - const libraries = await this.repository.getAll(false); + const libraries = await this.libraryRepository.getAll(false); return libraries.map((library) => mapLibrary(library)); } async handleQueueCleanup(): Promise { this.logger.debug('Cleaning up any pending library deletions'); - const pendingDeletion = await this.repository.getAllDeleted(); + const pendingDeletion = await this.libraryRepository.getAllDeleted(); await this.jobRepository.queueAll( pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), ); @@ -241,29 +228,37 @@ export class LibraryService { } async create(dto: CreateLibraryDto): Promise { - const library = await this.repository.create({ + const library = await this.libraryRepository.create({ ownerId: dto.ownerId, name: dto.name ?? 'New External Library', importPaths: dto.importPaths ?? [], - exclusionPatterns: dto.exclusionPatterns ?? [], + exclusionPatterns: dto.exclusionPatterns ?? ['**/@eaDir/**', '**/._*'], }); return mapLibrary(library); } - private async scanAssets(libraryId: string, assetPaths: string[], ownerId: string, force = false) { + private async syncFiles({ id, ownerId }: LibraryEntity, assetPaths: string[]) { await this.jobRepository.queueAll( assetPaths.map((assetPath) => ({ - name: JobName.LIBRARY_SCAN_ASSET, + name: JobName.LIBRARY_SYNC_FILE, data: { - id: libraryId, + id, assetPath, ownerId, - force, }, })), ); } + private async syncAssets({ importPaths, exclusionPatterns }: LibraryEntity, assetIds: string[]) { + await this.jobRepository.queueAll( + assetIds.map((assetId) => ({ + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: assetId, importPaths, exclusionPatterns }, + })), + ); + } + private async validateImportPath(importPath: string): Promise { const validation = new ValidateLibraryImportPathResponseDto(); validation.importPath = importPath; @@ -308,7 +303,7 @@ export class LibraryService { async update(id: string, dto: UpdateLibraryDto): Promise { await this.findOrFail(id); - const library = await this.repository.update({ id, ...dto }); + const library = await this.libraryRepository.update({ id, ...dto }); if (dto.importPaths) { const validation = await this.validate(id, { importPaths: dto.importPaths }); @@ -331,7 +326,7 @@ export class LibraryService { await this.unwatch(id); } - await this.repository.softDelete(id); + await this.libraryRepository.softDelete(id); await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); } @@ -361,84 +356,44 @@ export class LibraryService { if (!assetsFound) { this.logger.log(`Deleting library ${libraryId}`); - await this.repository.delete(libraryId); + await this.libraryRepository.delete(libraryId); } return JobStatus.SUCCESS; } - async handleAssetRefresh(job: ILibraryFileJob): Promise { + async handleSyncFile(job: ILibraryFileJob): Promise { + // Only needs to handle new assets const assetPath = path.normalize(job.assetPath); - const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); + let asset = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); + if (asset) { + return JobStatus.SKIPPED; + } - let stats: Stats; + let stat; try { - stats = await this.storageRepository.stat(assetPath); - } catch (error: Error | any) { - // Can't access file, probably offline - if (existingAssetEntity) { - // Mark asset as offline - this.logger.debug(`Marking asset as offline: ${assetPath}`); - - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true }); - return JobStatus.SUCCESS; - } else { - // File can't be accessed and does not already exist in db - throw new BadRequestException('Cannot access file', { cause: error }); + stat = await this.storageRepository.stat(assetPath); + } catch (error: any) { + if (error.code === 'ENOENT') { + this.logger.error(`File not found: ${assetPath}`); + return JobStatus.SKIPPED; } + this.logger.error(`Error reading file: ${assetPath}. Error: ${error}`); + return JobStatus.FAILED; } - let doImport = false; - let doRefresh = false; - - if (job.force) { - doRefresh = true; - } - - const originalFileName = parse(assetPath).base; - - if (!existingAssetEntity) { - // This asset is new to us, read it from disk - this.logger.debug(`Importing new asset: ${assetPath}`); - doImport = true; - } else if (stats.mtime.toISOString() !== existingAssetEntity.fileModifiedAt.toISOString()) { - // File modification time has changed since last time we checked, re-read from disk - this.logger.debug( - `File modification time has changed, re-importing asset: ${assetPath}. Old mtime: ${existingAssetEntity.fileModifiedAt}. New mtime: ${stats.mtime}`, - ); - doRefresh = true; - } else if (existingAssetEntity.originalFileName !== originalFileName) { - // TODO: We can likely remove this check in the second half of 2024 when all assets have likely been re-imported by all users - this.logger.debug( - `Asset is missing file extension, re-importing: ${assetPath}. Current incorrect filename: ${existingAssetEntity.originalFileName}.`, - ); - doRefresh = true; - } else if (!job.force && stats && !existingAssetEntity.isOffline) { - // Asset exists on disk and in db and mtime has not changed. Also, we are not forcing refresn. Therefore, do nothing - this.logger.debug(`Asset already exists in database and on disk, will not import: ${assetPath}`); - } - - if (stats && existingAssetEntity?.isOffline) { - // File was previously offline but is now online - this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`); - await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false }); - doRefresh = true; - } + this.logger.log(`Importing new library asset: ${assetPath}`); - if (!doImport && !doRefresh) { - // If we don't import, exit here - return JobStatus.SKIPPED; + const library = await this.libraryRepository.get(job.id, true); + if (!library || library.deletedAt) { + this.logger.error('Cannot import asset into deleted library'); + return JobStatus.FAILED; } - let assetType: AssetType; + // TODO: device asset id is deprecated, remove it + const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); - if (mimeTypes.isImage(assetPath)) { - assetType = AssetType.IMAGE; - } else if (mimeTypes.isVideo(assetPath)) { - assetType = AssetType.VIDEO; - } else { - throw new BadRequestException(`Unsupported file type ${assetPath}`); - } + const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); // TODO: doesn't xmp replace the file extension? Will need investigation let sidecarPath: string | null = null; @@ -446,178 +401,138 @@ export class LibraryService { sidecarPath = `${assetPath}.xmp`; } - // TODO: device asset id is deprecated, remove it - const deviceAssetId = `${basename(assetPath)}`.replaceAll(/\s+/g, ''); - - let assetId; - if (doImport) { - const library = await this.repository.get(job.id, true); - if (library?.deletedAt) { - this.logger.error('Cannot import asset into deleted library'); - return JobStatus.FAILED; - } + const assetType = mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE; - const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); - - // TODO: In wait of refactoring the domain asset service, this function is just manually written like this - const addedAsset = await this.assetRepository.create({ - ownerId: job.ownerId, - libraryId: job.id, - checksum: pathHash, - originalPath: assetPath, - deviceAssetId, - deviceId: 'Library Import', - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - localDateTime: stats.mtime, - type: assetType, - originalFileName, - sidecarPath, - isExternal: true, - }); - assetId = addedAsset.id; - } else if (doRefresh && existingAssetEntity) { - assetId = existingAssetEntity.id; - await this.assetRepository.updateAll([existingAssetEntity.id], { - fileCreatedAt: stats.mtime, - fileModifiedAt: stats.mtime, - originalFileName, - }); - } else { - // Not importing and not refreshing, do nothing - return JobStatus.SKIPPED; - } + const mtime = stat.mtime; - this.logger.debug(`Queueing metadata extraction for: ${assetPath}`); + asset = await this.assetRepository.create({ + ownerId: job.ownerId, + libraryId: job.id, + checksum: pathHash, + originalPath: assetPath, + deviceAssetId, + deviceId: 'Library Import', + fileCreatedAt: mtime, + fileModifiedAt: mtime, + localDateTime: mtime, + type: assetType, + originalFileName: parse(assetPath).base, - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' } }); + sidecarPath, + isExternal: true, + }); - if (assetType === AssetType.VIDEO) { - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); - } + await this.queuePostSyncJobs(asset); return JobStatus.SUCCESS; } - async queueScan(id: string, dto: ScanLibraryDto) { + async queuePostSyncJobs(asset: AssetEntity) { + this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); + + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + } + + async queueScan(id: string) { await this.findOrFail(id); await this.jobRepository.queue({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id, - refreshModifiedFiles: dto.refreshModifiedFiles ?? false, - refreshAllFiles: dto.refreshAllFiles ?? false, }, }); + await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); } - async queueRemoveOffline(id: string) { - this.logger.verbose(`Queueing offline file removal from library ${id}`); - await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); - } - - async handleQueueAllScan(job: IBaseJob): Promise { - this.logger.debug(`Refreshing all external libraries: force=${job.force}`); + async handleQueueSyncAll(): Promise { + this.logger.debug(`Refreshing all external libraries`); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); - const libraries = await this.repository.getAll(true); + const libraries = await this.libraryRepository.getAll(true); + await this.jobRepository.queueAll( + libraries.map((library) => ({ + name: JobName.LIBRARY_QUEUE_SYNC_FILES, + data: { + id: library.id, + }, + })), + ); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LIBRARY_SCAN, + name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id: library.id, - refreshModifiedFiles: !job.force, - refreshAllFiles: job.force ?? false, }, })), ); return JobStatus.SUCCESS; } - async handleOfflineCheck(job: ILibraryOfflineJob): Promise { + async handleSyncAsset(job: ILibraryAssetJob): Promise { const asset = await this.assetRepository.getById(job.id); - if (!asset) { - // Asset is no longer in the database, skip return JobStatus.SKIPPED; } - if (asset.isOffline) { - this.logger.verbose(`Asset is already offline: ${asset.originalPath}`); - return JobStatus.SUCCESS; - } + const markOffline = async (explanation: string) => { + if (!asset.isOffline) { + this.logger.debug(`${explanation}, removing: ${asset.originalPath}`); + await this.assetRepository.updateAll([asset.id], { isOffline: true, deletedAt: new Date() }); + } + }; const isInPath = job.importPaths.find((path) => asset.originalPath.startsWith(path)); if (!isInPath) { - this.logger.debug(`Asset is no longer in an import path, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is no longer in an import path'); return JobStatus.SUCCESS; } const isExcluded = job.exclusionPatterns.some((pattern) => picomatch.isMatch(asset.originalPath, pattern)); if (isExcluded) { - this.logger.debug(`Asset is covered by an exclusion pattern, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + await markOffline('Asset is covered by an exclusion pattern'); return JobStatus.SUCCESS; } - const fileExists = await this.storageRepository.checkFileExists(asset.originalPath, R_OK); - if (!fileExists) { - this.logger.debug(`Asset is no longer found on disk, marking offline: ${asset.originalPath}`); - await this.assetRepository.update({ id: asset.id, isOffline: true }); + let stat; + try { + stat = await this.storageRepository.stat(asset.originalPath); + } catch { + await markOffline('Asset is no longer on disk or is inaccessible because of permissions'); return JobStatus.SUCCESS; } - this.logger.verbose( - `Asset is found on disk, not covered by an exclusion pattern, and is in an import path, keeping online: ${asset.originalPath}`, - ); - - return JobStatus.SUCCESS; - } - - async handleRemoveOffline(job: IEntityJob): Promise { - this.logger.debug(`Removing offline assets for library ${job.id}`); + const mtime = stat.mtime; + const isAssetModified = mtime.toISOString() !== asset.fileModifiedAt.toISOString(); - const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id, true), - ); - - let offlineAssets = 0; - for await (const assets of assetPagination) { - offlineAssets += assets.length; - if (assets.length > 0) { - this.logger.debug(`Discovered ${offlineAssets} offline assets in library ${job.id}`); - await this.jobRepository.queueAll( - assets.map((asset) => ({ - name: JobName.ASSET_DELETION, - data: { - id: asset.id, - deleteOnDisk: false, - }, - })), - ); - this.logger.verbose(`Queued deletion of ${assets.length} offline assets in library ${job.id}`); - } + if (asset.isOffline || isAssetModified) { + this.logger.debug(`Asset was offline or modified, updating asset record ${asset.originalPath}`); + //TODO: When we have asset status, we need to leave deletedAt as is when status is trashed + await this.assetRepository.updateAll([asset.id], { + isOffline: false, + deletedAt: null, + fileCreatedAt: mtime, + fileModifiedAt: mtime, + originalFileName: parse(asset.originalPath).base, + }); } - if (offlineAssets) { - this.logger.debug(`Finished queueing deletion of ${offlineAssets} offline assets for library ${job.id}`); - } else { - this.logger.debug(`Found no offline assets to delete from library ${job.id}`); + if (isAssetModified) { + this.logger.debug(`Asset was modified, queuing metadata extraction for: ${asset.originalPath}`); + await this.queuePostSyncJobs(asset); } - return JobStatus.SUCCESS; } - async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise { - const library = await this.repository.get(job.id); + async handleQueueSyncFiles(job: IEntityJob): Promise { + const library = await this.libraryRepository.get(job.id); if (!library) { + this.logger.debug(`Library ${job.id} not found, skipping refresh`); return JobStatus.SKIPPED; } - this.logger.log(`Refreshing library ${library.id}`); + this.logger.log(`Refreshing library ${library.id} for new assets`); const validImportPaths: string[] = []; @@ -630,60 +545,71 @@ export class LibraryService { } } - if (validImportPaths.length === 0) { + if (validImportPaths) { + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); + + let count = 0; + + for await (const assetBatch of assetsOnDisk) { + count += assetBatch.length; + this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`); + await this.syncFiles(library, assetBatch); + this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + } + + if (count > 0) { + this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`); + } else { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); + } + } else { this.logger.warn(`No valid import paths found for library ${library.id}`); } - const assetsOnDisk = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - includeHidden: false, - exclusionPatterns: library.exclusionPatterns, - take: JOBS_LIBRARY_PAGINATION_SIZE, - }); + await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() }); - let crawledAssets = 0; + return JobStatus.SUCCESS; + } - for await (const assetBatch of assetsOnDisk) { - crawledAssets += assetBatch.length; - this.logger.debug(`Discovered ${crawledAssets} asset(s) on disk for library ${library.id}...`); - await this.scanAssets(job.id, assetBatch, library.ownerId, job.refreshAllFiles ?? false); - this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + async handleQueueSyncAssets(job: IEntityJob): Promise { + const library = await this.libraryRepository.get(job.id); + if (!library) { + return JobStatus.SKIPPED; } - if (crawledAssets) { - this.logger.debug(`Finished queueing scan of ${crawledAssets} assets on disk for library ${library.id}`); - } else { - this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); - } + this.logger.log(`Scanning library ${library.id} for removed assets`); const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => - this.assetRepository.getWith(pagination, WithProperty.IS_ONLINE, job.id), + this.assetRepository.getAll(pagination, { libraryId: job.id, withDeleted: true }), ); - let onlineAssetCount = 0; + let assetCount = 0; for await (const assets of onlineAssets) { - onlineAssetCount += assets.length; - this.logger.debug(`Discovered ${onlineAssetCount} asset(s) in library ${library.id}...`); + assetCount += assets.length; + this.logger.debug(`Discovered ${assetCount} asset(s) in library ${library.id}...`); await this.jobRepository.queueAll( assets.map((asset) => ({ - name: JobName.LIBRARY_CHECK_OFFLINE, - data: { id: asset.id, importPaths: validImportPaths, exclusionPatterns: library.exclusionPatterns }, + name: JobName.LIBRARY_SYNC_ASSET, + data: { id: asset.id, importPaths: library.importPaths, exclusionPatterns: library.exclusionPatterns }, })), ); - this.logger.debug(`Queued online check of ${assets.length} asset(s) in library ${library.id}...`); + this.logger.debug(`Queued check of ${assets.length} asset(s) in library ${library.id}...`); } - if (onlineAssetCount) { - this.logger.log(`Finished queueing online check of ${onlineAssetCount} assets for library ${library.id}`); + if (assetCount) { + this.logger.log(`Finished queueing check of ${assetCount} assets for library ${library.id}`); } - await this.repository.update({ id: job.id, refreshedAt: new Date() }); - return JobStatus.SUCCESS; } private async findOrFail(id: string) { - const library = await this.repository.get(id); + const library = await this.libraryRepository.get(id); if (!library) { throw new BadRequestException('Library not found'); } diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index f8b73260aff3d..fde2ba7e0fe35 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -1,34 +1,23 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { MapService } from 'src/services/map.service'; +import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { partnerStub } from 'test/fixtures/partner.stub'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MapService.name, () => { let sut: MapService; + let albumMock: Mocked; - let loggerMock: Mocked; - let partnerMock: Mocked; let mapMock: Mocked; - let systemMetadataMock: Mocked; + let partnerMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - mapMock = newMapRepositoryMock(); - systemMetadataMock = newSystemMetadataRepositoryMock(); - - sut = new MapService(albumMock, loggerMock, partnerMock, mapMock, systemMetadataMock); + ({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService)); }); describe('getMapMarkers', () => { @@ -50,5 +39,62 @@ describe(MapService.name, () => { expect(markers).toHaveLength(1); expect(markers[0]).toEqual(marker); }); + + it('should include partner assets', async () => { + const asset = assetStub.withLocation; + const marker = { + id: asset.id, + lat: asset.exifInfo!.latitude!, + lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + state: asset.exifInfo!.state, + country: asset.exifInfo!.country, + }; + partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1]); + mapMock.getMapMarkers.mockResolvedValue([marker]); + + const markers = await sut.getMapMarkers(authStub.user1, { withPartners: true }); + + expect(mapMock.getMapMarkers).toHaveBeenCalledWith( + [authStub.user1.user.id, partnerStub.adminToUser1.sharedById], + expect.arrayContaining([]), + { withPartners: true }, + ); + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual(marker); + }); + + it('should include assets from shared albums', async () => { + const asset = assetStub.withLocation; + const marker = { + id: asset.id, + lat: asset.exifInfo!.latitude!, + lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + state: asset.exifInfo!.state, + country: asset.exifInfo!.country, + }; + partnerMock.getAll.mockResolvedValue([]); + mapMock.getMapMarkers.mockResolvedValue([marker]); + albumMock.getOwned.mockResolvedValue([albumStub.empty]); + albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); + + const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true }); + + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual(marker); + }); + }); + + describe('reverseGeocode', () => { + it('should reverse geocode a location', async () => { + mapMock.reverseGeocode.mockResolvedValue({ city: 'foo', state: 'bar', country: 'baz' }); + + await expect(sut.reverseGeocode({ lat: 42, lon: 69 })).resolves.toEqual([ + { city: 'foo', state: 'bar', country: 'baz' }, + ]); + + expect(mapMock.reverseGeocode).toHaveBeenCalledWith({ latitude: 42, longitude: 69 }); + }); }); }); diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts index ffd84a3e02bf6..860a782e79a0b 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,28 +1,9 @@ -import { Inject } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMapRepository } from 'src/interfaces/map.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; -export class MapService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IMapRepository) private mapRepository: IMapRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - ) { - this.logger.setContext(MapService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } - +export class MapService extends BaseService { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds = [auth.user.id]; if (options.withPartners) { @@ -43,17 +24,6 @@ export class MapService { return this.mapRepository.getMapMarkers(userIds, albumIds, options); } - async getMapStyle(theme: 'light' | 'dark') { - const { map } = await this.configCore.getConfig({ withCache: false }); - const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle; - - if (styleUrl) { - return this.mapRepository.fetchStyle(styleUrl); - } - - return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); - } - async reverseGeocode(dto: MapReverseGeocodeDto) { const { lat: latitude, lon: longitude } = dto; // eventually this should probably return an array of results diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index bf493de0f39d1..54f69bc41083f 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,5 +1,8 @@ import { Stats } from 'node:fs'; +import { ExifEntity } from 'src/entities/exif.entity'; import { + AssetFileType, + AssetType, AudioCodec, Colorspace, ImageFormat, @@ -7,15 +10,11 @@ import { TranscodeHWAccel, TranscodePolicy, VideoCodec, -} from 'src/config'; -import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetFileType, AssetType } from 'src/enum'; +} from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; +import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -24,51 +23,23 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MediaService.name, () => { let sut: MediaService; + let assetMock: Mocked; let jobMock: Mocked; + let loggerMock: Mocked; let mediaMock: Mocked; - let moveMock: Mocked; let personMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; - let cryptoMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - jobMock = newJobRepositoryMock(); - mediaMock = newMediaRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new MediaService( - assetMock, - personMock, - jobMock, - mediaMock, - storageMock, - systemMock, - moveMock, - cryptoMock, - loggerMock, - ); + ({ sut, assetMock, jobMock, loggerMock, mediaMock, personMock, storageMock, systemMock } = + newTestService(MediaService)); }); it('should be defined', () => { @@ -93,7 +64,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -126,7 +97,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.trashed.id }, }, ]); @@ -151,7 +122,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.archived.id }, }, ]); @@ -201,7 +172,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PREVIEW, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -225,7 +196,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAIL, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -249,7 +220,7 @@ describe(MediaService.name, () => { expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL); expect(jobMock.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBHASH, + name: JobName.GENERATE_THUMBNAILS, data: { id: assetStub.image.id }, }, ]); @@ -258,10 +229,19 @@ describe(MediaService.name, () => { }); }); - describe('handleGeneratePreview', () => { + describe('handleGenerateThumbnails', () => { + let rawBuffer: Buffer; + let rawInfo: RawImageInfo; + + beforeEach(() => { + rawBuffer = Buffer.from('image data'); + rawInfo = { width: 100, height: 100, channels: 3 }; + mediaMock.decodeImage.mockResolvedValue({ data: rawBuffer, info: rawInfo }); + }); + it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); @@ -269,86 +249,106 @@ describe(MediaService.name, () => { it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + assetMock.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); - expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); - it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; - - await sut.handleGeneratePreview({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', previewPath, { - size: 1440, - format, - quality: 80, - colorspace: Colorspace.SRGB, - processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: previewPath, - }); - }); - it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + assetMock.getById.mockResolvedValue(assetStub.image); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([ - { ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity }, - ]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); + it('should generate P3 thumbnails for a wide gamut image', async () => { + assetMock.getById.mockResolvedValue({ + ...assetStub.image, + exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity, + }); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/original/path.jpg', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + rawBuffer, { - size: 1440, + colorspace: Colorspace.P3, format: ImageFormat.JPEG, + size: 1440, quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { colorspace: Colorspace.P3, + format: ImageFormat.WEBP, + size: 250, + quality: 80, processInvalidImages: false, + raw: rawInfo, }, + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith(rawBuffer, { + colorspace: Colorspace.P3, + processInvalidImages: false, + raw: rawInfo, }); + + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); + expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); }); it('should generate a thumbnail for a video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -358,25 +358,32 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should tonemap thumbnail for hdr video', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -386,13 +393,20 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - }); + expect(assetMock.upsertFiles).toHaveBeenCalledWith([ + { + assetId: 'asset-id', + type: AssetFileType.PREVIEW, + path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + }, + { + assetId: 'asset-id', + type: AssetFileType.THUMBNAIL, + path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + }, + ]); }); it('should always generate video thumbnail in one pass', async () => { @@ -400,13 +414,13 @@ describe(MediaService.name, () => { systemMock.get.mockResolvedValue({ ffmpeg: { twoPass: true, maxBitrate: '5000k' }, }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -416,254 +430,228 @@ describe(MediaService.name, () => { String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); }); it('should use scaling divisible by 2 even when using quick sync', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGeneratePreview({ id: assetStub.video.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await sut.handleGenerateThumbnails({ id: assetStub.video.id }); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]), twoPass: false, - }, + }), ); }); - it('should run successfully', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - await sut.handleGeneratePreview({ id: assetStub.image.id }); - }); - }); - - describe('handleGenerateThumbnail', () => { - it('should skip thumbnail generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); - - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); - - expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - - expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); + it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; - it.each(Object.values(ImageFormat))( - 'should generate a %s thumbnail for an image when specified', - async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, { - size: 250, + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { + colorspace: Colorspace.SRGB, format, + size: 1440, quality: 80, + processInvalidImages: false, + raw: rawInfo, + }, + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + { colorspace: Colorspace.SRGB, + format: ImageFormat.WEBP, + size: 250, + quality: 80, processInvalidImages: false, - }); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: thumbnailPath, - }); - }, - ); - - it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); + raw: rawInfo, + }, + thumbnailPath, + ); }); - }); - it('should generate a P3 thumbnail for a wide gamut image', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, - colorspace: Colorspace.P3, - processInvalidImages: false, - }, - ); - expect(assetMock.upsertFile).toHaveBeenCalledWith({ - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - }); - }); + it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => { + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; + const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; - it('should extract embedded image if enabled and available', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { + colorspace: Colorspace.SRGB, + processInvalidImages: false, + size: 1440, + }); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ - extractedPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, { - format: ImageFormat.WEBP, - size: 250, + colorspace: Colorspace.SRGB, + format: ImageFormat.JPEG, + size: 1440, quality: 80, - colorspace: Colorspace.P3, processInvalidImages: false, + raw: rawInfo, }, - ], - ]); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); - - it('should resize original image if embedded image is too small', async () => { - mediaMock.extract.mockResolvedValue(true); - mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); - - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); - - expect(mediaMock.generateThumbnail.mock.calls).toEqual([ - [ - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + previewPath, + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, { - format: ImageFormat.WEBP, + colorspace: Colorspace.SRGB, + format, size: 250, quality: 80, - colorspace: Colorspace.P3, processInvalidImages: false, + raw: rawInfo, }, - ], - ]); - const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); - expect(extractedPath?.endsWith('.tmp')).toBe(true); - expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); - }); + thumbnailPath, + ); + }); + + it('should delete previous thumbnail if different path', async () => { + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + assetMock.getById.mockResolvedValue(assetStub.image); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - it('should resize original image if embedded image not found', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext'); + }); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + it('should extract embedded image if enabled and available', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(extractedPath, { colorspace: Colorspace.P3, processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); + size: 1440, + }); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); - it('should resize original image if embedded image extraction is not enabled', async () => { - systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image is too small', async () => { + mediaMock.extract.mockResolvedValue(true); + mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.extract).not.toHaveBeenCalled(); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - }); - - it('should process invalid images if enabled', async () => { - vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); + size: 1440, + }); + const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString(); + expect(extractedPath?.endsWith('.tmp')).toBe(true); + expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath); + }); - assetMock.getByIds.mockResolvedValue([assetStub.imageDng]); + it('should resize original image if embedded image not found', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: true } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - await sut.handleGenerateThumbnail({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.imageDng.originalPath, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', - { - format: ImageFormat.WEBP, - size: 250, - quality: 80, + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { colorspace: Colorspace.P3, - processInvalidImages: true, - }, - ); - expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); - vi.unstubAllEnvs(); - }); - - describe('handleGenerateThumbhash', () => { - it('should skip thumbhash generation if asset not found', async () => { - assetMock.getByIds.mockResolvedValue([]); - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); }); - it('should skip thumbhash generation if resize path is missing', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); - await sut.handleGenerateThumbhash({ id: assetStub.noResizePath.id }); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); + it('should resize original image if embedded image extraction is not enabled', async () => { + systemMock.get.mockResolvedValue({ image: { extractEmbedded: false } }); + assetMock.getById.mockResolvedValue(assetStub.imageDng); + + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + + expect(mediaMock.extract).not.toHaveBeenCalled(); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith(assetStub.imageDng.originalPath, { + colorspace: Colorspace.P3, + processInvalidImages: false, + size: 1440, + }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); }); - it('should skip invisible assets', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]); + it('should process invalid images if enabled', async () => { + vi.stubEnv('IMMICH_PROCESS_INVALID_IMAGES', 'true'); - expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + assetMock.getById.mockResolvedValue(assetStub.imageDng); - expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); - expect(assetMock.update).not.toHaveBeenCalledWith(); - }); + await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - it('should generate a thumbhash', async () => { - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer); + expect(mediaMock.decodeImage).toHaveBeenCalledOnce(); + expect(mediaMock.decodeImage).toHaveBeenCalledWith( + assetStub.imageDng.originalPath, + expect.objectContaining({ processInvalidImages: true }), + ); + + expect(mediaMock.generateThumbnail).toHaveBeenCalledTimes(2); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + ); + expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + ); - await sut.handleGenerateThumbhash({ id: assetStub.image.id }); + expect(mediaMock.generateThumbhash).toHaveBeenCalledOnce(); + expect(mediaMock.generateThumbhash).toHaveBeenCalledWith( + rawBuffer, + expect.objectContaining({ processInvalidImages: true }), + ); - expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg'); - expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); + expect(mediaMock.getImageDimensions).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); }); }); @@ -730,21 +718,22 @@ describe(MediaService.name, () => { it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); + loggerMock.isLevelEnabled.mockReturnValue(false); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); + expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:1']), twoPass: false, - }, + }), ); }); @@ -770,11 +759,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -785,11 +774,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -800,11 +789,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -815,11 +804,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]), twoPass: false, - }, + }), ); }); @@ -831,11 +820,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]), twoPass: false, - }, + }), ); }); @@ -847,11 +836,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]), twoPass: false, - }, + }), ); }); @@ -863,11 +852,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]), twoPass: false, - }, + }), ); }); @@ -879,11 +868,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]), twoPass: false, - }, + }), ); }); @@ -897,11 +886,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']), twoPass: false, - }, + }), ); }); @@ -919,11 +908,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining(['-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -941,11 +930,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -957,11 +946,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -972,11 +961,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1035,11 +1024,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), twoPass: false, - }, + }), ); }); @@ -1051,11 +1040,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1067,11 +1056,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1089,11 +1078,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1111,11 +1100,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]), twoPass: true, - }, + }), ); }); @@ -1127,11 +1116,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-cpu-used 2']), twoPass: false, - }, + }), ); }); @@ -1143,11 +1132,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]), twoPass: false, - }, + }), ); }); @@ -1159,11 +1148,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 2']), twoPass: false, - }, + }), ); }); @@ -1175,11 +1164,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1191,11 +1180,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1207,11 +1196,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1223,11 +1212,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1239,7 +1228,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ '-c:v av1', @@ -1254,7 +1243,7 @@ describe(MediaService.name, () => { '-crf 23', ]), twoPass: false, - }, + }), ); }); @@ -1266,11 +1255,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-preset 4']), twoPass: false, - }, + }), ); }); @@ -1282,11 +1271,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1298,11 +1287,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4']), twoPass: false, - }, + }), ); }); @@ -1314,11 +1303,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1360,7 +1349,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([ '-tune hq', @@ -1381,7 +1370,7 @@ describe(MediaService.name, () => { '-cq:v 23', ]), twoPass: false, - }, + }), ); }); @@ -1399,11 +1388,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1415,11 +1404,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']), twoPass: false, - }, + }), ); }); @@ -1431,11 +1420,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.stringContaining('-maxrate'), twoPass: false, - }, + }), ); }); @@ -1447,11 +1436,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1463,11 +1452,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1481,7 +1470,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel cuda', '-hwaccel_output_format cuda', @@ -1490,7 +1479,7 @@ describe(MediaService.name, () => { ]), outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), twoPass: false, - }, + }), ); }); @@ -1504,7 +1493,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1512,7 +1501,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1525,7 +1514,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining([ `-c:v h264_qsv`, @@ -1546,7 +1535,7 @@ describe(MediaService.name, () => { '-bufsize 20000k', ]), twoPass: false, - }, + }), ); }); @@ -1565,14 +1554,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw', ]), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1585,11 +1574,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1602,11 +1591,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining(['-low_power 1']), twoPass: false, - }, + }), ); }); @@ -1632,7 +1621,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1644,7 +1633,7 @@ describe(MediaService.name, () => { expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), ]), twoPass: false, - }, + }), ); }); @@ -1661,7 +1650,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', '-hwaccel_output_format qsv', @@ -1674,7 +1663,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1690,11 +1679,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1707,7 +1696,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1727,7 +1716,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1740,7 +1729,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1753,7 +1742,7 @@ describe(MediaService.name, () => { '-rc_mode 3', ]), twoPass: false, - }, + }), ); }); @@ -1766,7 +1755,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1779,7 +1768,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1792,14 +1781,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]), twoPass: false, - }, + }), ); }); @@ -1812,14 +1801,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1832,14 +1821,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD130', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1854,14 +1843,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1876,11 +1865,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), twoPass: false, - }, + }), ); }); @@ -1903,7 +1892,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel rkmpp', '-hwaccel_output_format drm_prime', @@ -1926,7 +1915,7 @@ describe(MediaService.name, () => { '-qp_init 23', ]), twoPass: false, - }, + }), ); }); @@ -1947,11 +1936,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']), twoPass: false, - }, + }), ); }); @@ -1967,11 +1956,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']), twoPass: false, - }, + }), ); }); @@ -1987,7 +1976,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -1995,7 +1984,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2011,7 +2000,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2019,7 +2008,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2035,7 +2024,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2043,69 +2032,101 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); - }); - it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + it('should tonemap when policy is required and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); - it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + it('should tonemap when policy is optimal and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should count frames for progress when log level is debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + loggerMock.isLevelEnabled.mockReturnValue(true); + assetMock.getByIds.mockResolvedValue([assetStub.video]); - it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.transcode).toHaveBeenCalledWith( - '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + assetStub.video.originalPath, + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + progress: { + frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + percentInterval: expect.any(Number), + }, + }, + ); + }); + + it('should not count frames for progress when log level is not debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + loggerMock.isLevelEnabled.mockReturnValue(false); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + }); }); describe('isSRGB', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 919348b53ef99..e4fd91f363b57 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,75 +1,44 @@ -import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { dirname } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; +import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; import { + AssetFileType, + AssetPathType, + AssetType, AudioCodec, Colorspace, - ImageFormat, + LogLevel, + StorageFolder, TranscodeHWAccel, TranscodePolicy, TranscodeTarget, VideoCodec, VideoContainer, -} from 'src/config'; -import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; -import { AssetFileType, AssetType } from 'src/enum'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +} from 'src/enum'; +import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { IBaseJob, IEntityJob, - IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobItem, JobName, JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { AudioStreamInfo, TranscodeCommand, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { mimeTypes } from 'src/utils/mime-types'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class MediaService { - private configCore: SystemConfigCore; - private storageCore: StorageCore; +export class MediaService extends BaseService { private maliOpenCL?: boolean; private devices?: string[]; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IMediaRepository) private mediaRepository: IMediaRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(MediaService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force @@ -87,18 +56,10 @@ export class MediaService { for (const asset of assets) { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); - if (!previewFile || force) { - jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } }); + if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { + jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); continue; } - - if (!thumbnailFile) { - jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } }); - } - - if (!asset.thumbhash) { - jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } }); - } } await this.jobRepository.queueAll(jobs); @@ -117,7 +78,7 @@ export class MediaService { continue; } - await this.personRepository.update([{ id: person.id, faceAssetId: face.id }]); + await this.personRepository.update({ id: person.id, faceAssetId: face.id }); } jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); @@ -160,160 +121,140 @@ export class MediaService { } async handleAssetMigration({ id }: IEntityJob): Promise { - const { image } = await this.configCore.getConfig({ withCache: true }); + const { image } = await this.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id], { files: true }); if (!asset) { return JobStatus.FAILED; } - await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat); - await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.SUCCESS; } - async handleGeneratePreview({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + async handleGenerateThumbnails({ id }: IEntityJob): Promise { + const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); if (!asset) { + this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); return JobStatus.FAILED; } if (!asset.isVisible) { + this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); - if (!previewPath) { + let generated: { previewPath: string; thumbnailPath: string; thumbhash: Buffer }; + if (asset.type === AssetType.IMAGE) { + generated = await this.generateImageThumbnails(asset); + } else if (asset.type === AssetType.VIDEO) { + generated = await this.generateVideoThumbnails(asset); + } else { + this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); return JobStatus.SKIPPED; } - const { previewFile } = getAssetFiles(asset.files); - if (previewFile && previewFile.path !== previewPath) { - this.logger.debug(`Deleting old preview for asset ${asset.id}`); - await this.storageRepository.unlink(previewFile.path); + const { previewFile, thumbnailFile } = getAssetFiles(asset.files); + const toUpsert: UpsertFileOptions[] = []; + if (previewFile?.path !== generated.previewPath) { + toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() }); - - return JobStatus.SUCCESS; - } - - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) { - const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize; - const path = StorageCore.getImagePath(asset, type, format); - this.storageCore.ensureFolders(path); - - switch (asset.type) { - case AssetType.IMAGE: { - const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); - const extractedPath = StorageCore.getTempPathInDir(dirname(path)); - const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); - - try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); - const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; - const imageOptions = { - format, - size, - colorspace, - quality: image.quality, - processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - }; - - const outputPath = useExtracted ? extractedPath : asset.originalPath; - await this.mediaRepository.generateThumbnail(outputPath, path, imageOptions); - } finally { - if (didExtract) { - await this.storageRepository.unlink(extractedPath); - } - } - break; - } - - case AssetType.VIDEO: { - const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); - const mainVideoStream = this.getMainStream(videoStreams); - if (!mainVideoStream) { - this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`); - return; - } - const mainAudioStream = this.getMainStream(audioStreams); - const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() }); - const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); - await this.mediaRepository.transcode(asset.originalPath, path, options); - break; - } - - default: { - throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`); - } + if (thumbnailFile?.path !== generated.thumbnailPath) { + toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); } - const assetLabel = asset.isExternal ? asset.originalPath : asset.id; - this.logger.log( - `Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${assetLabel}`, - ); - - return path; - } + if (toUpsert.length > 0) { + await this.assetRepository.upsertFiles(toUpsert); + } - async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); - if (!asset) { - return JobStatus.FAILED; + const pathsToDelete = []; + if (previewFile && previewFile.path !== generated.previewPath) { + this.logger.debug(`Deleting old preview for asset ${asset.id}`); + pathsToDelete.push(previewFile.path); } - if (!asset.isVisible) { - return JobStatus.SKIPPED; + if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) { + this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); + pathsToDelete.push(thumbnailFile.path); } - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); - if (!thumbnailPath) { - return JobStatus.SKIPPED; + if (pathsToDelete.length > 0) { + await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); } - const { thumbnailFile } = getAssetFiles(asset.files); - if (thumbnailFile && thumbnailFile.path !== thumbnailPath) { - this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`); - await this.storageRepository.unlink(thumbnailFile.path); + if (asset.thumbhash != generated.thumbhash) { + await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash }); } - await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath }); - await this.assetRepository.update({ id: asset.id, updatedAt: new Date() }); - await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() }); + await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); return JobStatus.SUCCESS; } - async handleGenerateThumbhash({ id }: IEntityJob): Promise { - const [asset] = await this.assetRepository.getByIds([id], { files: true }); - if (!asset) { - return JobStatus.FAILED; - } + private async generateImageThumbnails(asset: AssetEntity) { + const { image } = await this.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); - if (!asset.isVisible) { - return JobStatus.SKIPPED; + const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath); + const extractedPath = StorageCore.getTempPathInDir(dirname(previewPath)); + const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); + + try { + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); + const inputPath = useExtracted ? extractedPath : asset.originalPath; + const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; + const processInvalidImages = process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true'; + + const decodeOptions = { colorspace, processInvalidImages, size: image.preview.size }; + const { data, info } = await this.mediaRepository.decodeImage(inputPath, decodeOptions); + + const options = { colorspace, processInvalidImages, raw: info }; + const outputs = await Promise.all([ + this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...options }, thumbnailPath), + this.mediaRepository.generateThumbnail(data, { ...image.preview, ...options }, previewPath), + this.mediaRepository.generateThumbhash(data, options), + ]); + + return { previewPath, thumbnailPath, thumbhash: outputs[2] }; + } finally { + if (didExtract) { + await this.storageRepository.unlink(extractedPath); + } } + } - const { previewFile } = getAssetFiles(asset.files); - if (!previewFile) { - return JobStatus.FAILED; + private async generateVideoThumbnails(asset: AssetEntity) { + const { image, ffmpeg } = await this.getConfig({ withCache: true }); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + this.storageCore.ensureFolders(previewPath); + + const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); + const mainVideoStream = this.getMainStream(videoStreams); + if (!mainVideoStream) { + throw new Error(`No video streams found for asset ${asset.id}`); } + const mainAudioStream = this.getMainStream(audioStreams); - const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path); - await this.assetRepository.update({ id: asset.id, thumbhash }); + const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); + const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - return JobStatus.SUCCESS; + const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + const thumbnailOptions = thumbnailConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream); + await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions); + + const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, { + colorspace: image.colorspace, + processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', + }); + + return { previewPath, thumbnailPath, thumbhash }; } async handleQueueVideoConversion(job: IBaseJob): Promise { @@ -344,7 +285,9 @@ export class MediaService { const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); - const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); + const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { + countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs + }); const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); if (!mainVideoStream || !format.formatName) { @@ -356,19 +299,21 @@ export class MediaService { return JobStatus.FAILED; } - const { ffmpeg } = await this.configCore.getConfig({ withCache: true }); + const { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream); if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + } else { + this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } return JobStatus.SKIPPED; } - let command; + let command: TranscodeCommand; try { const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); command = config.getCommand(target, mainVideoStream, mainAudioStream); @@ -377,16 +322,20 @@ export class MediaService { return JobStatus.FAILED; } - this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + this.logger.log(`Encoding video ${asset.id} without hardware acceleration`); + } else { + this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`); + } + try { await this.mediaRepository.transcode(input, output, command); - } catch (error) { - this.logger.error(error); - if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { - this.logger.error( - `Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`, - ); + } catch (error: any) { + this.logger.error(`Error occurred during transcoding: ${error.message}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + return JobStatus.FAILED; } + this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); command = config.getCommand(target, mainVideoStream, mainAudioStream); await this.mediaRepository.transcode(input, output, command); @@ -553,7 +502,7 @@ export class MediaService { const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); } catch { - this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.'); + this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding'); this.maliOpenCL = false; } } diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index ba184daa801bf..b5dd4c2553f4a 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -5,20 +5,18 @@ import { MemoryService } from 'src/services/memory.service'; import { authStub } from 'test/fixtures/auth.stub'; import { memoryStub } from 'test/fixtures/memory.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MemoryService.name, () => { + let sut: MemoryService; + let accessMock: IAccessRepositoryMock; let memoryMock: Mocked; - let sut: MemoryService; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - memoryMock = newMemoryRepositoryMock(); - - sut = new MemoryService(accessMock, memoryMock); + ({ sut, accessMock, memoryMock } = newTestService(MemoryService)); }); it('should be defined', () => { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index fb1ff49f0b456..f7d1ead6aaad9 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,28 +1,22 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IMemoryRepository } from 'src/interfaces/memory.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() -export class MemoryService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IMemoryRepository) private repository: IMemoryRepository, - ) {} - +export class MemoryService extends BaseService { async search(auth: AuthDto) { - const memories = await this.repository.search(auth.user.id); + const memories = await this.memoryRepository.search(auth.user.id); return memories.map((memory) => mapMemory(memory)); } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] }); const memory = await this.findOrFail(id); return mapMemory(memory); } @@ -31,12 +25,12 @@ export class MemoryService { // TODO validate type/data combination const assetIds = dto.assetIds || []; - const allowedAssetIds = await checkAccess(this.access, { + const allowedAssetIds = await checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: assetIds, }); - const memory = await this.repository.create({ + const memory = await this.memoryRepository.create({ ownerId: auth.user.id, type: dto.type, data: dto.data, @@ -50,9 +44,9 @@ export class MemoryService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); - const memory = await this.repository.update({ + const memory = await this.memoryRepository.update({ id, isSaved: dto.isSaved, memoryAt: dto.memoryAt, @@ -63,28 +57,28 @@ export class MemoryService { } async remove(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.MEMORY_DELETE, ids: [id] }); - await this.repository.delete(id); + await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_DELETE, ids: [id] }); + await this.memoryRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] }); - const repos = { access: this.access, bulk: this.repository }; + const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids }); const hasSuccess = results.find(({ success }) => success); if (hasSuccess) { - await this.repository.update({ id, updatedAt: new Date() }); + await this.memoryRepository.update({ id, updatedAt: new Date() }); } return results; } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); - const repos = { access: this.access, bulk: this.repository }; + const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await removeAssets(auth, repos, { parentId: id, assetIds: dto.ids, @@ -93,14 +87,14 @@ export class MemoryService { const hasSuccess = results.find(({ success }) => success); if (hasSuccess) { - await this.repository.update({ id, updatedAt: new Date() }); + await this.memoryRepository.update({ id, updatedAt: new Date() }); } return results; } private async findOrFail(id: string) { - const memory = await this.repository.get(id); + const memory = await this.memoryRepository.get(id); if (!memory) { throw new BadRequestException('Memory not found'); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 52f6609772b9d..cd7f68ab1dd8c 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -3,18 +3,15 @@ import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { constants } from 'node:fs/promises'; import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetType, SourceType } from 'src/enum'; +import { AssetType, ImmichWorker, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -27,79 +24,45 @@ import { probeStub } from 'test/fixtures/media.stub'; import { metadataStub } from 'test/fixtures/metadata.stub'; import { personStub } from 'test/fixtures/person.stub'; import { tagStub } from 'test/fixtures/tag.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; -import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; -import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(MetadataService.name, () => { + let sut: MetadataService; + let albumMock: Mocked; let assetMock: Mocked; - let systemMock: Mocked; - let cryptoRepository: Mocked; + let cryptoMock: Mocked; + let eventMock: Mocked; let jobMock: Mocked; let mapMock: Mocked; - let metadataMock: Mocked; - let moveMock: Mocked; let mediaMock: Mocked; + let metadataMock: Mocked; let personMock: Mocked; let storageMock: Mocked; - let eventMock: Mocked; - let databaseMock: Mocked; - let userMock: Mocked; - let loggerMock: Mocked; + let systemMock: Mocked; let tagMock: Mocked; - let sut: MetadataService; + let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); - cryptoRepository = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - mapMock = newMapRepositoryMock(); - metadataMock = newMetadataRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); - eventMock = newEventRepositoryMock(); - storageMock = newStorageRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - mediaMock = newMediaRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - tagMock = newTagRepositoryMock(); - - sut = new MetadataService( + ({ + sut, albumMock, assetMock, + cryptoMock, eventMock, - cryptoRepository, - databaseMock, jobMock, mapMock, mediaMock, metadataMock, - moveMock, personMock, storageMock, systemMock, tagMock, userMock, - loggerMock, - ); + } = newTestService(MetadataService)); + + delete process.env.TZ; }); afterEach(async () => { @@ -112,7 +75,7 @@ describe(MetadataService.name, () => { describe('onBootstrapEvent', () => { it('should pause and resume queue during init', async () => { - await sut.onBootstrap('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(jobMock.pause).toHaveBeenCalledTimes(1); expect(mapMock.init).toHaveBeenCalledTimes(1); @@ -122,7 +85,7 @@ describe(MetadataService.name, () => { it('should return if reverse geocoding is disabled', async () => { systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); - await sut.onBootstrap('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(jobMock.pause).not.toHaveBeenCalled(); expect(mapMock.init).not.toHaveBeenCalled(); @@ -220,11 +183,10 @@ describe(MetadataService.name, () => { await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( JobStatus.SUCCESS, ); - expect(eventMock.clientSend).toHaveBeenCalledWith( - ClientEvent.ASSET_HIDDEN, - assetStub.livePhotoMotionAsset.ownerId, - assetStub.livePhotoMotionAsset.id, - ); + expect(eventMock.emit).toHaveBeenCalledWith('asset.hide', { + userId: assetStub.livePhotoMotionAsset.ownerId, + assetId: assetStub.livePhotoMotionAsset.id, + }); }); it('should search by libraryId', async () => { @@ -287,7 +249,7 @@ describe(MetadataService.name, () => { it('should handle an asset that could not be found', async () => { await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalled(); }); @@ -305,7 +267,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate })); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, @@ -315,12 +277,33 @@ describe(MetadataService.name, () => { }); }); + it('should account for the server being in a non-UTC timezone', async () => { + process.env.TZ = 'America/Los_Angeles'; + assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); + metadataMock.readTags.mockResolvedValueOnce({ + DateTimeOriginal: '2022:01:01 00:00:00', + }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + dateTimeOriginal: new Date('2022-01-01T08:00:00.000Z'), + }), + ); + + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + localDateTime: new Date('2022-01-01T00:00:00.000Z'), + }), + ); + }); + it('should handle lists of numbers', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ ISO: [160] as any }); + metadataMock.readTags.mockResolvedValue({ ISO: [160] }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 })); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, @@ -340,7 +323,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), ); @@ -360,7 +343,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null })); }); @@ -412,7 +395,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list with a number', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] }); + metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] }); tagMock.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -453,7 +436,7 @@ describe(MetadataService.name, () => { it('should extract hierarchy from HierarchicalSubject', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent|Child'] }); + metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); @@ -465,6 +448,18 @@ describe(MetadataService.name, () => { value: 'Parent/Child', parent: tagStub.parent, }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); + }); + + it('should extract tags from HierarchicalSubject as a list with a number', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent', 2024] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { @@ -510,8 +505,10 @@ describe(MetadataService.name, () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { + faces: { person: false }, + }); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith( @@ -519,16 +516,26 @@ describe(MetadataService.name, () => { ); }); + it('should handle an invalid Directory Item', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ + MotionPhoto: 1, + ContainerDirectory: [{ Foo: 100 }], + }); + + await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + }); + it('should extract the correct video orientation', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - metadataMock.readTags.mockResolvedValue(null); + metadataMock.readTags.mockResolvedValue({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW }), + expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }), ); }); @@ -542,10 +549,10 @@ describe(MetadataService.name, () => { EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); metadataMock.extractBinaryTag.mockResolvedValue(video); @@ -554,7 +561,9 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'MotionPhotoVideo', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + faces: { person: false }, + }); expect(assetMock.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', @@ -571,7 +580,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -585,10 +594,10 @@ describe(MetadataService.name, () => { EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoType: 'MotionPhoto_Data', }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); metadataMock.extractBinaryTag.mockResolvedValue(video); @@ -597,7 +606,9 @@ describe(MetadataService.name, () => { assetStub.livePhotoWithOriginalFileName.originalPath, 'EmbeddedVideoFile', ); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + faces: { person: false }, + }); expect(assetMock.create).toHaveBeenCalledWith({ checksum: expect.any(Buffer), deviceAssetId: 'NONE', @@ -614,7 +625,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -629,15 +640,17 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); - cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); + cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); const video = randomBytes(512); storageMock.readFile.mockResolvedValue(video); await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoWithOriginalFileName.id], { + faces: { person: false }, + }); expect(storageMock.readFile).toHaveBeenCalledWith( assetStub.livePhotoWithOriginalFileName.originalPath, expect.any(Object), @@ -658,7 +671,7 @@ describe(MetadataService.name, () => { type: AssetType.VIDEO, }); expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); - expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); + expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(assetMock.update).toHaveBeenNthCalledWith(1, { id: assetStub.livePhotoWithOriginalFileName.id, livePhotoVideoId: fileStub.livePhotoMotion.uuid, @@ -673,7 +686,7 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset })); const video = randomBytes(512); @@ -698,7 +711,7 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); storageMock.readFile.mockResolvedValue(video); @@ -706,7 +719,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(assetMock.create).toHaveBeenCalledTimes(0); - expect(storageMock.writeFile).toHaveBeenCalledTimes(0); + expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0); // The still asset gets saved by handleMetadataExtraction, but not the video expect(assetMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queue).toHaveBeenCalledTimes(0); @@ -720,7 +733,7 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true }); const video = randomBytes(512); storageMock.readFile.mockResolvedValue(video); @@ -746,7 +759,7 @@ describe(MetadataService.name, () => { MicroVideo: 1, MicroVideoOffset: 1, }); - cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); + cryptoMock.hashSha1.mockReturnValue(randomBytes(512)); assetMock.getByChecksum.mockResolvedValue(null); assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset); const video = randomBytes(512); @@ -786,7 +799,7 @@ describe(MetadataService.name, () => { metadataMock.readTags.mockResolvedValue(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: assetStub.image.id, bitsPerSample: expect.any(Number), @@ -814,6 +827,9 @@ describe(MetadataService.name, () => { projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, rating: tags.Rating, + country: null, + state: null, + city: null, }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, @@ -841,7 +857,7 @@ describe(MetadataService.name, () => { metadataMock.readTags.mockResolvedValue(tags); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ timeZone: 'UTC+0', @@ -861,7 +877,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -882,7 +898,7 @@ describe(MetadataService.name, () => { }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -904,7 +920,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -926,7 +942,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id], { faces: { person: false } }); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -989,13 +1005,11 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName); personMock.getDistinctNames.mockResolvedValue([]); - personMock.create.mockResolvedValue([]); - personMock.replaceFaces.mockResolvedValue([]); - personMock.update.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.create).toHaveBeenCalledWith([]); - expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); - expect(personMock.update).toHaveBeenCalledWith([]); + expect(personMock.createAll).not.toHaveBeenCalled(); + expect(personMock.refreshFaces).not.toHaveBeenCalled(); + expect(personMock.updateAll).not.toHaveBeenCalled(); }); it('should skip importing faces with empty name', async () => { @@ -1003,13 +1017,11 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName); personMock.getDistinctNames.mockResolvedValue([]); - personMock.create.mockResolvedValue([]); - personMock.replaceFaces.mockResolvedValue([]); - personMock.update.mockResolvedValue([]); + personMock.createAll.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.create).toHaveBeenCalledWith([]); - expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); - expect(personMock.update).toHaveBeenCalledWith([]); + expect(personMock.createAll).not.toHaveBeenCalled(); + expect(personMock.refreshFaces).not.toHaveBeenCalled(); + expect(personMock.updateAll).not.toHaveBeenCalled(); }); it('should apply metadata face tags creating new persons', async () => { @@ -1017,15 +1029,13 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFace); personMock.getDistinctNames.mockResolvedValue([]); - personMock.create.mockResolvedValue([personStub.withName]); - personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); - personMock.update.mockResolvedValue([personStub.withName]); + personMock.createAll.mockResolvedValue([personStub.withName.id]); + personMock.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.create).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); - expect(personMock.replaceFaces).toHaveBeenCalledWith( - assetStub.primaryImage.id, + expect(personMock.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); + expect(personMock.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1040,9 +1050,9 @@ describe(MetadataService.name, () => { sourceType: SourceType.EXIF, }, ], - SourceType.EXIF, + [], ); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); + expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, @@ -1056,15 +1066,13 @@ describe(MetadataService.name, () => { systemMock.get.mockResolvedValue({ metadata: { faces: { import: true } } }); metadataMock.readTags.mockResolvedValue(metadataStub.withFace); personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); - personMock.create.mockResolvedValue([]); - personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); - personMock.update.mockResolvedValue([personStub.withName]); + personMock.createAll.mockResolvedValue([]); + personMock.update.mockResolvedValue(personStub.withName); await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.primaryImage.id], { faces: { person: false } }); expect(personMock.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true }); - expect(personMock.create).toHaveBeenCalledWith([]); - expect(personMock.replaceFaces).toHaveBeenCalledWith( - assetStub.primaryImage.id, + expect(personMock.createAll).not.toHaveBeenCalled(); + expect(personMock.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1079,10 +1087,46 @@ describe(MetadataService.name, () => { sourceType: SourceType.EXIF, }, ], - SourceType.EXIF, + [], + ); + expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalledWith(); + }); + + it('should handle invalid modify date', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ ModifyDate: '00:00:00.000' }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + modifyDate: expect.any(Date), + }), + ); + }); + + it('should handle invalid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 6 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: null, + }), + ); + }); + + it('should handle valid rating value', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ Rating: 5 }); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + rating: 5, + }), ); - expect(personMock.update).toHaveBeenCalledWith([]); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); }); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 58e7b994480ac..a81d1b4904c27 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,5 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; +import { Injectable } from '@nestjs/common'; +import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; import { Duration } from 'luxon'; @@ -7,38 +7,27 @@ import { constants } from 'node:fs/promises'; import path from 'node:path'; import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, SourceType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { AssetType, ImmichWorker, SourceType } from 'src/enum'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, - IJobRepository, ISidecarWriteJob, JobName, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMapRepository } from 'src/interfaces/map.interface'; -import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { ITagRepository } from 'src/interfaces/tag.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { ReverseGeocodeResult } from 'src/interfaces/map.interface'; +import { ImmichTags } from 'src/interfaces/metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { isFaceImportEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { upsertTags } from 'src/utils/tag'; @@ -56,23 +45,16 @@ const EXIF_DATE_TAGS: Array = [ ]; export enum Orientation { - Horizontal = '1', - MirrorHorizontal = '2', - Rotate180 = '3', - MirrorVertical = '4', - MirrorHorizontalRotate270CW = '5', - Rotate90CW = '6', - MirrorHorizontalRotate90CW = '7', - Rotate270CW = '8', + Horizontal = 1, + MirrorHorizontal = 2, + Rotate180 = 3, + MirrorVertical = 4, + MirrorHorizontalRotate270CW = 5, + Rotate90CW = 6, + MirrorHorizontalRotate90CW = 7, + Rotate270CW = 8, } -type ExifEntityWithoutGeocodeAndTypeOrm = Omit & { - dateTimeOriginal: Date; -}; - -const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null); -const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null); - const validate = (value: T): NonNullable | null => { // handle lists of numbers if (Array.isArray(value)) { @@ -91,52 +73,35 @@ const validate = (value: T): NonNullable | null => { return value ?? null; }; -@Injectable() -export class MetadataService { - private storageCore: StorageCore; - private configCore: SystemConfigCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IMapRepository) private mapRepository: IMapRepository, - @Inject(IMediaRepository) private mediaRepository: IMediaRepository, - @Inject(IMetadataRepository) private repository: IMetadataRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ITagRepository) private tagRepository: ITagRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(MetadataService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); +const validateRange = (value: number | undefined, min: number, max: number): NonNullable | null => { + // reutilizes the validate function + const val = validate(value); + + // check if the value is within the range + if (val == null || val < min || val > max) { + return null; } - @OnEmit({ event: 'app.bootstrap' }) + return val; +}; + +@Injectable() +export class MetadataService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { - if (app !== 'microservices') { + if (app !== ImmichWorker.MICROSERVICES) { return; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.init(config); } - @OnEmit({ event: 'config.update' }) + @OnEvent({ name: 'app.shutdown' }) + async onShutdown() { + await this.metadataRepository.teardown(); + } + + @OnEvent({ name: 'config.update' }) async onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { await this.init(newConfig); } @@ -159,11 +124,6 @@ export class MetadataService { } } - @OnEmit({ event: 'app.shutdown' }) - async onShutdown() { - await this.repository.teardown(); - } - async handleLivePhotoLinking(job: IEntityJob): Promise { const { id } = job; const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); @@ -194,8 +154,7 @@ export class MetadataService { await this.assetRepository.update({ id: motionAsset.id, isVisible: false }); await this.albumRepository.removeAsset(motionAsset.id); - // Notify clients to hide the linked live photo asset - this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id); + await this.eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); return JobStatus.SUCCESS; } @@ -218,36 +177,73 @@ export class MetadataService { } async handleMetadataExtraction({ id }: IEntityJob): Promise { - const { metadata } = await this.configCore.getConfig({ withCache: true }); - const [asset] = await this.assetRepository.getByIds([id]); + const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); + const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } }); if (!asset) { return JobStatus.FAILED; } - const { exifData, exifTags } = await this.exifData(asset); + const stats = await this.storageRepository.stat(asset.originalPath); - if (asset.type === AssetType.VIDEO) { - await this.applyVideoMetadata(asset, exifData); - } + const exifTags = await this.getExifTags(asset); - await this.applyMotionPhotos(asset, exifTags); - await this.applyReverseGeocoding(asset, exifData); - await this.applyTagList(asset, exifTags); + this.logger.verbose('Exif Tags', exifTags); - await this.assetRepository.upsertExif(exifData); + const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); + const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); - const dateTimeOriginal = exifData.dateTimeOriginal; - let localDateTime = dateTimeOriginal ?? undefined; + const exifData: Partial = { + assetId: asset.id, - const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0; + // dates + dateTimeOriginal, + modifyDate, + timeZone, - if (dateTimeOriginal && timeZoneOffset) { - localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000); - } + // gps + latitude, + longitude, + country, + state, + city, + + // image/file + fileSizeInByte: stats.size, + exifImageHeight: validate(exifTags.ImageHeight), + exifImageWidth: validate(exifTags.ImageWidth), + orientation: validate(exifTags.Orientation)?.toString() ?? null, + projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, + bitsPerSample: this.getBitsPerSample(exifTags), + colorspace: exifTags.ColorSpace ?? null, + + // camera + make: exifTags.Make ?? null, + model: exifTags.Model ?? null, + fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), + iso: validate(exifTags.ISO) as number, + exposureTime: exifTags.ExposureTime ?? null, + lensModel: exifTags.LensModel ?? null, + fNumber: validate(exifTags.FNumber), + focalLength: validate(exifTags.FocalLength), + + // comments + description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), + profileDescription: exifTags.ProfileDescription || null, + rating: validateRange(exifTags.Rating, 0, 5), + + // grouping + livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, + autoStackId: this.getAutoStackId(exifTags), + }; + + await this.applyTagList(asset, exifTags); + await this.applyMotionPhotos(asset, exifTags); + + await this.assetRepository.upsertExif(exifData); await this.assetRepository.update({ id: asset.id, - duration: asset.duration, + duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, }); @@ -292,12 +288,12 @@ export class MetadataService { return this.processSidecar(id, false); } - @OnEmit({ event: 'asset.tag' }) + @OnEvent({ name: 'asset.tag' }) async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } - @OnEmit({ event: 'asset.untag' }) + @OnEvent({ name: 'asset.untag' }) async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) { await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); } @@ -329,7 +325,7 @@ export class MetadataService { return JobStatus.SKIPPED; } - await this.repository.writeTags(sidecarPath, exif); + await this.metadataRepository.writeTags(sidecarPath, exif); if (!asset.sidecarPath) { await this.assetRepository.update({ id, sidecarPath }); @@ -338,35 +334,30 @@ export class MetadataService { return JobStatus.SUCCESS; } - private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { - const { latitude, longitude } = exifData; - const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); - if (!reverseGeocoding.enabled || !longitude || !latitude) { - return; - } + private async getExifTags(asset: AssetEntity): Promise { + const mediaTags = await this.metadataRepository.readTags(asset.originalPath); + const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {}; + const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {}; - try { - const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude }); - if (!reverseGeocode) { - return; + // make sure dates comes from sidecar + const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS); + if (sidecarDate) { + for (const tag of EXIF_DATE_TAGS) { + delete mediaTags[tag]; } - Object.assign(exifData, reverseGeocode); - } catch (error: Error | any) { - this.logger.warn( - `Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`, - error?.stack, - ); } + + return { ...mediaTags, ...videoTags, ...sidecarTags }; } private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { - const tags: unknown[] = []; + const tags: string[] = []; if (exifTags.TagsList) { - tags.push(...exifTags.TagsList); + tags.push(...exifTags.TagsList.map(String)); } else if (exifTags.HierarchicalSubject) { tags.push( - exifTags.HierarchicalSubject.map((tag) => - tag + ...exifTags.HierarchicalSubject.map((tag) => + String(tag) // convert | to / .replaceAll('/', '') .replaceAll('|', '/') @@ -378,10 +369,10 @@ export class MetadataService { if (!Array.isArray(keywords)) { keywords = [keywords]; } - tags.push(...keywords); + tags.push(...keywords.map(String)); } - const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) }); + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) }); } @@ -404,7 +395,7 @@ export class MetadataService { if (isMotionPhoto && directory) { for (const entry of directory) { - if (entry.Item.Semantic == 'MotionPhoto') { + if (entry?.Item?.Semantic == 'MotionPhoto') { length = entry.Item.Length ?? 0; padding = entry.Item.Padding ?? 0; break; @@ -429,11 +420,11 @@ export class MetadataService { // Samsung MotionPhoto video extraction // HEIC-encoded if (hasMotionPhotoVideo) { - video = await this.repository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo'); + video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo'); } // JPEG-encoded; HEIC also contains these tags, so this conditional must come second else if (hasEmbeddedVideoFile) { - video = await this.repository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile'); + video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile'); } // Default video extraction else { @@ -506,7 +497,7 @@ export class MetadataService { const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath); if (!existsOnDisk) { this.storageCore.ensureFolders(motionAsset.originalPath); - await this.storageRepository.writeFile(motionAsset.originalPath, video); + await this.storageRepository.createFile(motionAsset.originalPath, video); this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); } @@ -522,7 +513,7 @@ export class MetadataService { return; } - const discoveredFaces: Partial[] = []; + const facesToAdd: Partial[] = []; const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); const missing: Partial[] = []; @@ -550,7 +541,7 @@ export class MetadataService { sourceType: SourceType.EXIF, }; - discoveredFaces.push(face); + facesToAdd.push(face); if (!existingNameMap.has(loweredName)) { missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); missingWithFaceAsset.push({ id: personId, faceAssetId: face.id }); @@ -559,83 +550,88 @@ export class MetadataService { if (missing.length > 0) { this.logger.debug(`Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); + const newPersonIds = await this.personRepository.createAll(missing); + const jobs = newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }) as const); + await this.jobRepository.queueAll(jobs); } - const newPersons = await this.personRepository.create(missing); + const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.EXIF).map((face) => face.id); + if (facesToRemove.length > 0) { + this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}`); + } - const faceIds = await this.personRepository.replaceFaces(asset.id, discoveredFaces, SourceType.EXIF); - this.logger.debug(`Created ${faceIds.length} faces for asset ${asset.id}`); + if (facesToAdd.length > 0) { + this.logger.debug(`Creating ${facesToAdd} faces from metadata for asset ${asset.id}`); + } - await this.personRepository.update(missingWithFaceAsset); + if (facesToRemove.length > 0 || facesToAdd.length > 0) { + await this.personRepository.refreshFaces(facesToAdd, facesToRemove); + } - await this.jobRepository.queueAll( - newPersons.map((person) => ({ - name: JobName.GENERATE_PERSON_THUMBNAIL, - data: { id: person.id }, - })), - ); + if (missingWithFaceAsset.length > 0) { + await this.personRepository.updateAll(missingWithFaceAsset); + } } - private async exifData( - asset: AssetEntity, - ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> { - const stats = await this.storageRepository.stat(asset.originalPath); - const mediaTags = await this.repository.readTags(asset.originalPath); - const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null; + private getDates(asset: AssetEntity, exifTags: ImmichTags) { + const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); + this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`); - // ensure date from sidecar is used if present - const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags); - if (mediaTags && hasDateOverride) { - for (const tag of EXIF_DATE_TAGS) { - delete mediaTags[tag]; - } + // timezone + let timeZone = exifTags.tz ?? null; + if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + timeZone = 'UTC+0'; } - const exifTags = { ...mediaTags, ...sidecarTags }; + if (timeZone) { + this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); + } else { + this.logger.warn(`Asset ${asset.id} has no time zone information`); + } - this.logger.verbose('Exif Tags', exifTags); + let dateTimeOriginal = dateTime?.toDate(); + let localDateTime = dateTime?.toDateTime().setZone('UTC', { keepLocalTime: true }).toJSDate(); + if (!localDateTime || !dateTimeOriginal) { + this.logger.warn(`Asset ${asset.id} has no valid date, falling back to asset.fileCreatedAt`); + dateTimeOriginal = asset.fileCreatedAt; + localDateTime = asset.fileCreatedAt; + } - const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags); - const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt; - const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue); + this.logger.debug(`Asset ${asset.id} has a local time of ${localDateTime.toISOString()}`); - const exifData = { - // altitude: tags.GPSAltitude ?? null, - assetId: asset.id, - bitsPerSample: this.getBitsPerSample(exifTags), - colorspace: exifTags.ColorSpace ?? null, + let modifyDate = asset.fileModifiedAt; + try { + modifyDate = (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? modifyDate; + } catch {} + + return { dateTimeOriginal, - description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), - exifImageHeight: validate(exifTags.ImageHeight), - exifImageWidth: validate(exifTags.ImageWidth), - exposureTime: exifTags.ExposureTime ?? null, - fileSizeInByte: stats.size, - fNumber: validate(exifTags.FNumber), - focalLength: validate(exifTags.FocalLength), - fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), - iso: validate(exifTags.ISO), - latitude: validate(exifTags.GPSLatitude), - lensModel: exifTags.LensModel ?? null, - livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, - autoStackId: this.getAutoStackId(exifTags), - longitude: validate(exifTags.GPSLongitude), - make: exifTags.Make ?? null, - model: exifTags.Model ?? null, - modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt, - orientation: validate(exifTags.Orientation)?.toString() ?? null, - profileDescription: exifTags.ProfileDescription || null, - projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, timeZone, - rating: exifTags.Rating ?? null, + localDateTime, + modifyDate, }; + } - if (exifData.latitude === 0 && exifData.longitude === 0) { - this.logger.warn('Exif data has latitude and longitude of 0, setting to null'); - exifData.latitude = null; - exifData.longitude = null; + private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) { + let latitude = validate(tags.GPSLatitude); + let longitude = validate(tags.GPSLongitude); + + // TODO take ref into account + + if (latitude === 0 && longitude === 0) { + this.logger.warn('Latitude and longitude of 0, setting to null'); + latitude = null; + longitude = null; } - return { exifData, exifTags }; + let result: ReverseGeocodeResult = { country: null, state: null, city: null }; + if (reverseGeocoding.enabled && longitude && latitude) { + result = await this.mapRepository.reverseGeocode({ latitude, longitude }); + } + + return { ...result, latitude, longitude }; } private getAutoStackId(tags: ImmichTags | null): string | null { @@ -645,28 +641,6 @@ export class MetadataService { return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null; } - private getDateTimeOriginal(tags: ImmichTags | Tags | null) { - return this.getDateTimeOriginalWithRawValue(tags).exifDate; - } - - private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } { - if (!tags) { - return { exifDate: null, rawValue: '' }; - } - const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS); - return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' }; - } - - private getTimeZone(exifTags: ImmichTags, rawValue: string) { - const timeZone = exifTags.tz ?? null; - if (timeZone == null && rawValue.endsWith('+00:00')) { - // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly - // https://github.com/photostructure/exiftool-vendored.js/issues/203 - return 'UTC+0'; - } - return timeZone; - } - private getBitsPerSample(tags: ImmichTags): number | null { const bitDepthTags = [ tags.BitsPerSample, @@ -685,33 +659,37 @@ export class MetadataService { return bitsPerSample; } - private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { - const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath); + private async getVideoTags(originalPath: string) { + const { videoStreams, format } = await this.mediaRepository.probe(originalPath); + + const tags: Pick = {}; if (videoStreams[0]) { switch (videoStreams[0].rotation) { case -90: { - exifData.orientation = Orientation.Rotate90CW; + tags.Orientation = Orientation.Rotate90CW; break; } case 0: { - exifData.orientation = Orientation.Horizontal; + tags.Orientation = Orientation.Horizontal; break; } case 90: { - exifData.orientation = Orientation.Rotate270CW; + tags.Orientation = Orientation.Rotate270CW; break; } case 180: { - exifData.orientation = Orientation.Rotate180; + tags.Orientation = Orientation.Rotate180; break; } } } if (format.duration) { - asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); + tags.Duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); } + + return tags; } private async processSidecar(id: string, isSync: boolean): Promise { diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 025400cc9bde3..d1d2bb8f20d76 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; +import { ImmichWorker } from 'src/enum'; import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; @@ -15,6 +16,8 @@ import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; +import { TagService } from 'src/services/tag.service'; +import { TrashService } from 'src/services/trash.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { otelShutdown } from 'src/utils/instrumentation'; @@ -34,14 +37,16 @@ export class MicroservicesService { private sessionService: SessionService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, + private tagService: TagService, + private trashService: TrashService, private userService: UserService, private duplicateService: DuplicateService, private versionService: VersionService, ) {} - @OnEmit({ event: 'app.bootstrap' }) + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { - if (app !== 'microservices') { + if (app !== ImmichWorker.MICROSERVICES) { return; } @@ -64,9 +69,7 @@ export class MicroservicesService { [JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data), [JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), - [JobName.GENERATE_PREVIEW]: (data) => this.mediaService.handleGeneratePreview(data), - [JobName.GENERATE_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbnail(data), - [JobName.GENERATE_THUMBHASH]: (data) => this.mediaService.handleGenerateThumbhash(data), + [JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data), @@ -82,18 +85,20 @@ export class MicroservicesService { [JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data), [JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data), [JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data), - [JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data), - [JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data), + [JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(), + [JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk + [JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files + [JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets + [JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed [JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data), - [JobName.LIBRARY_CHECK_OFFLINE]: (data) => this.libraryService.handleOfflineCheck(data), - [JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleRemoveOffline(data), - [JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data), [JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(), [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), + [JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(), }); } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 9d9f8f5fcfe2f..028e512b3968d 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -8,7 +8,6 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -16,14 +15,7 @@ import { NotificationService } from 'src/services/notification.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const configs = { @@ -64,42 +56,34 @@ const configs = { }; describe(NotificationService.name, () => { + let sut: NotificationService; + let albumMock: Mocked; let assetMock: Mocked; let eventMock: Mocked; let jobMock: Mocked; - let loggerMock: Mocked; let notificationMock: Mocked; - let sut: NotificationService; let systemMock: Mocked; let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - notificationMock = newNotificationRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - - sut = new NotificationService( - eventMock, - systemMock, - notificationMock, - userMock, - jobMock, - loggerMock, - assetMock, - albumMock, - ); + ({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } = + newTestService(NotificationService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('onConfigUpdate', () => { + it('should emit client and server events', () => { + const update = { newConfig: defaults }; + expect(sut.onConfigUpdate(update)).toBeUndefined(); + expect(eventMock.clientBroadcast).toHaveBeenCalledWith('on_config_update'); + expect(eventMock.serverSend).toHaveBeenCalledWith('config.update', update); + }); + }); + describe('onConfigValidateEvent', () => { it('validates smtp config when enabling smtp', async () => { const oldConfig = configs.smtpDisabled; @@ -142,6 +126,31 @@ describe(NotificationService.name, () => { await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(notificationMock.verifySmtp).not.toHaveBeenCalled(); }); + + it('should fail if smtp configuration is invalid', async () => { + const oldConfig = configs.smtpDisabled; + const newConfig = configs.smtpEnabled; + + notificationMock.verifySmtp.mockRejectedValue(new Error('Failed validating smtp')); + await expect(sut.onConfigValidate({ oldConfig, newConfig })).rejects.toBeInstanceOf(Error); + }); + }); + + describe('onAssetHide', () => { + it('should send connected clients an event', () => { + sut.onAssetHide({ assetId: 'asset-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_hidden', 'user-id', 'asset-id'); + }); + }); + + describe('onAssetShow', () => { + it('should queue the generate thumbnail job', async () => { + await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_THUMBNAILS, + data: { id: 'asset-id', notify: true }, + }); + }); }); describe('onUserSignupEvent', () => { @@ -179,6 +188,74 @@ describe(NotificationService.name, () => { }); }); + describe('onSessionDeleteEvent', () => { + it('should send a on_session_delete client event', () => { + vi.useFakeTimers(); + sut.onSessionDelete({ sessionId: 'id' }); + expect(eventMock.clientSend).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(500); + + expect(eventMock.clientSend).toHaveBeenCalledWith('on_session_delete', 'id', 'id'); + }); + }); + + describe('onAssetTrash', () => { + it('should send connected clients an event', () => { + sut.onAssetTrash({ assetId: 'asset-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + }); + }); + + describe('onAssetDelete', () => { + it('should send connected clients an event', () => { + sut.onAssetDelete({ assetId: 'asset-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_delete', 'user-id', 'asset-id'); + }); + }); + + describe('onAssetsTrash', () => { + it('should send connected clients an event', () => { + sut.onAssetsTrash({ assetIds: ['asset-id'], userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_trash', 'user-id', ['asset-id']); + }); + }); + + describe('onAssetsRestore', () => { + it('should send connected clients an event', () => { + sut.onAssetsRestore({ assetIds: ['asset-id'], userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_restore', 'user-id', ['asset-id']); + }); + }); + + describe('onStackCreate', () => { + it('should send connected clients an event', () => { + sut.onStackCreate({ stackId: 'stack-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + }); + }); + + describe('onStackUpdate', () => { + it('should send connected clients an event', () => { + sut.onStackUpdate({ stackId: 'stack-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + }); + }); + + describe('onStackDelete', () => { + it('should send connected clients an event', () => { + sut.onStackDelete({ stackId: 'stack-id', userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + }); + }); + + describe('onStacksDelete', () => { + it('should send connected clients an event', () => { + sut.onStacksDelete({ stackIds: ['stack-id'], userId: 'user-id' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_asset_stack_update', 'user-id'); + }); + }); + describe('sendTestEmail', () => { it('should throw error if user could not be found', async () => { await expect(sut.sendTestEmail('', configs.smtpTransport.notifications.smtp)).rejects.toThrow('User not found'); @@ -543,11 +620,6 @@ describe(NotificationService.name, () => { await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); }); - it('should fail if email could not be sent', async () => { - systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true } } }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.FAILED); - }); - it('should send mail successfully', async () => { systemMock.get.mockResolvedValue({ notifications: { smtp: { enabled: true, from: 'test@immich.app' } } }); notificationMock.sendEmail.mockResolvedValue({ messageId: '', response: '' }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index d450f8dc759a2..f6b338d79e716 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,49 +1,33 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; import { IEmailJob, - IJobRepository, INotifyAlbumInviteJob, INotifyAlbumUpdateJob, INotifySignupJob, JobName, JobStatus, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getFilenameExtension } from 'src/utils/file'; import { isEqualObject } from 'src/utils/object'; import { getPreferences } from 'src/utils/preferences'; @Injectable() -export class NotificationService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(INotificationRepository) private notificationRepository: INotificationRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - ) { - this.logger.setContext(NotificationService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); +export class NotificationService extends BaseService { + @OnEvent({ name: 'config.update' }) + onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + this.eventRepository.clientBroadcast('on_config_update'); + this.eventRepository.serverSend('config.update', { oldConfig, newConfig }); } - @OnEmit({ event: 'config.validate', priority: -100 }) + @OnEvent({ name: 'config.validate', priority: -100 }) async onConfigValidate({ oldConfig, newConfig }: ArgOf<'config.validate'>) { try { if ( @@ -58,27 +42,77 @@ export class NotificationService { } } - @OnEmit({ event: 'user.signup' }) + @OnEvent({ name: 'asset.hide' }) + onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { + this.eventRepository.clientSend('on_asset_hidden', userId, assetId); + } + + @OnEvent({ name: 'asset.show' }) + async onAssetShow({ assetId }: ArgOf<'asset.show'>) { + await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); + } + + @OnEvent({ name: 'asset.trash' }) + onAssetTrash({ assetId, userId }: ArgOf<'asset.trash'>) { + this.eventRepository.clientSend('on_asset_trash', userId, [assetId]); + } + + @OnEvent({ name: 'asset.delete' }) + onAssetDelete({ assetId, userId }: ArgOf<'asset.delete'>) { + this.eventRepository.clientSend('on_asset_delete', userId, assetId); + } + + @OnEvent({ name: 'assets.trash' }) + onAssetsTrash({ assetIds, userId }: ArgOf<'assets.trash'>) { + this.eventRepository.clientSend('on_asset_trash', userId, assetIds); + } + + @OnEvent({ name: 'assets.restore' }) + onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { + this.eventRepository.clientSend('on_asset_restore', userId, assetIds); + } + + @OnEvent({ name: 'stack.create' }) + onStackCreate({ userId }: ArgOf<'stack.create'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'stack.update' }) + onStackUpdate({ userId }: ArgOf<'stack.update'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'stack.delete' }) + onStackDelete({ userId }: ArgOf<'stack.delete'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'stacks.delete' }) + onStacksDelete({ userId }: ArgOf<'stacks.delete'>) { + this.eventRepository.clientSend('on_asset_stack_update', userId); + } + + @OnEvent({ name: 'user.signup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'user.signup'>) { if (notify) { await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); } } - @OnEmit({ event: 'album.update' }) + @OnEvent({ name: 'album.update' }) async onAlbumUpdate({ id, updatedBy }: ArgOf<'album.update'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_UPDATE, data: { id, senderId: updatedBy } }); } - @OnEmit({ event: 'album.invite' }) + @OnEvent({ name: 'album.invite' }) async onAlbumInvite({ id, userId }: ArgOf<'album.invite'>) { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } - @OnEmit({ event: 'session.delete' }) + @OnEvent({ name: 'session.delete' }) onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { // after the response is sent - setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); + setTimeout(() => this.eventRepository.clientSend('on_session_delete', sessionId, sessionId), 500); } async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { @@ -90,10 +124,10 @@ export class NotificationService { try { await this.notificationRepository.verifySmtp(dto.transport); } catch (error) { - throw new HttpException('Failed to verify SMTP configuration', HttpStatus.BAD_REQUEST, { cause: error }); + throw new BadRequestException('Failed to verify SMTP configuration', { cause: error }); } - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.TEST_EMAIL, data: { @@ -102,7 +136,7 @@ export class NotificationService { }, }); - await this.notificationRepository.sendEmail({ + const { messageId } = await this.notificationRepository.sendEmail({ to: user.email, subject: 'Test email from Immich', html, @@ -111,6 +145,8 @@ export class NotificationService { replyTo: dto.replyTo || dto.from, smtp: dto.transport, }); + + return { messageId }; } async handleUserSignup({ id, tempPassword }: INotifySignupJob) { @@ -119,7 +155,7 @@ export class NotificationService { return JobStatus.SKIPPED; } - const { server } = await this.configCore.getConfig({ withCache: true }); + const { server } = await this.getConfig({ withCache: true }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.WELCOME, data: { @@ -162,7 +198,7 @@ export class NotificationService { const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); const { html, text } = await this.notificationRepository.renderEmail({ template: EmailTemplate.ALBUM_INVITE, data: { @@ -204,7 +240,7 @@ export class NotificationService { const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) => user.id !== senderId); const attachment = await this.getAlbumThumbnailAttachment(album); - const { server } = await this.configCore.getConfig({ withCache: false }); + const { server } = await this.getConfig({ withCache: false }); for (const recipient of recipients) { const user = await this.userRepository.get(recipient.id, { withDeleted: false }); @@ -245,7 +281,7 @@ export class NotificationService { } async handleSendEmail(data: IEmailJob): Promise { - const { notifications } = await this.configCore.getConfig({ withCache: false }); + const { notifications } = await this.getConfig({ withCache: false }); if (!notifications.smtp.enabled) { return JobStatus.SKIPPED; } @@ -262,10 +298,6 @@ export class NotificationService { imageAttachments: data.imageAttachments, }); - if (!response) { - return JobStatus.FAILED; - } - this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`); return JobStatus.SUCCESS; diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index b2b3401251cb6..2e11c4f9adecb 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -1,20 +1,20 @@ import { BadRequestException } from '@nestjs/common'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; import { PartnerService } from 'src/services/partner.service'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(PartnerService.name, () => { let sut: PartnerService; + + let accessMock: IAccessRepositoryMock; let partnerMock: Mocked; - let accessMock: Mocked; beforeEach(() => { - partnerMock = newPartnerRepositoryMock(); - sut = new PartnerService(partnerMock, accessMock); + ({ sut, accessMock, partnerMock } = newTestService(PartnerService)); }); it('should work', () => { @@ -74,4 +74,24 @@ describe(PartnerService.name, () => { expect(partnerMock.remove).not.toHaveBeenCalled(); }); }); + + describe('update', () => { + it('should require access', async () => { + await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: false })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('should update partner', async () => { + accessMock.partner.checkUpdateAccess.mockResolvedValue(new Set(['shared-by-id'])); + partnerMock.update.mockResolvedValue(partnerStub.adminToUser1); + + await expect(sut.update(authStub.admin, 'shared-by-id', { inTimeline: true })).resolves.toBeDefined(); + expect(partnerMock.update).toHaveBeenCalledWith({ + sharedById: 'shared-by-id', + sharedWithId: authStub.admin.user.id, + inTimeline: true, + }); + }); + }); }); diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 4b7cd4c516e42..39907ec5fe11f 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -1,43 +1,38 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto'; import { mapUser } from 'src/dtos/user.dto'; import { PartnerEntity } from 'src/entities/partner.entity'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IPartnerRepository, PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; @Injectable() -export class PartnerService { - constructor( - @Inject(IPartnerRepository) private repository: IPartnerRepository, - @Inject(IAccessRepository) private access: IAccessRepository, - ) {} - +export class PartnerService extends BaseService { async create(auth: AuthDto, sharedWithId: string): Promise { const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; - const exists = await this.repository.get(partnerId); + const exists = await this.partnerRepository.get(partnerId); if (exists) { throw new BadRequestException(`Partner already exists`); } - const partner = await this.repository.create(partnerId); + const partner = await this.partnerRepository.create(partnerId); return this.mapPartner(partner, PartnerDirection.SharedBy); } async remove(auth: AuthDto, sharedWithId: string): Promise { const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId }; - const partner = await this.repository.get(partnerId); + const partner = await this.partnerRepository.get(partnerId); if (!partner) { throw new BadRequestException('Partner not found'); } - await this.repository.remove(partner); + await this.partnerRepository.remove(partner); } async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise { - const partners = await this.repository.getAll(auth.user.id); + const partners = await this.partnerRepository.getAll(auth.user.id); const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId'; return partners .filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users @@ -46,10 +41,10 @@ export class PartnerService { } async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; - const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline }); + const entity = await this.partnerRepository.update({ ...partnerId, inTimeline: dto.inTimeline }); return this.mapPartner(entity, PartnerDirection.SharedWith); } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 51598b93d063b..d3d0b457c7c57 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,39 +1,26 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { Colorspace } from 'src/config'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { SourceType, SystemMetadataKey } from 'src/enum'; +import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { PersonService } from 'src/services/person.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { personStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { IsNull } from 'typeorm'; import { Mocked } from 'vitest'; @@ -48,65 +35,63 @@ const responseDto: PersonResponseDto = { const statistics = { assets: 3 }; +const faceId = 'face-id'; +const face = { + id: faceId, + assetId: 'asset-id', + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageHeight: 500, + imageWidth: 400, +}; +const faceSearch = { faceId, embedding: [1, 2, 3, 4] }; const detectFaceMock: DetectedFaces = { faces: [ { boundingBox: { - x1: 100, - y1: 100, - x2: 200, - y2: 200, + x1: face.boundingBoxX1, + y1: face.boundingBoxY1, + x2: face.boundingBoxX2, + y2: face.boundingBoxY2, }, - embedding: [1, 2, 3, 4], + embedding: faceSearch.embedding, score: 0.2, }, ], - imageHeight: 500, - imageWidth: 400, + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, }; describe(PersonService.name, () => { + let sut: PersonService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let systemMock: Mocked; + let cryptoMock: Mocked; let jobMock: Mocked; let machineLearningMock: Mocked; let mediaMock: Mocked; - let moveMock: Mocked; let personMock: Mocked; - let storageMock: Mocked; let searchMock: Mocked; - let cryptoMock: Mocked; - let loggerMock: Mocked; - let sut: PersonService; + let storageMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - jobMock = newJobRepositoryMock(); - machineLearningMock = newMachineLearningRepositoryMock(); - moveMock = newMoveRepositoryMock(); - mediaMock = newMediaRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - searchMock = newSearchRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new PersonService( + ({ + sut, accessMock, assetMock, + cryptoMock, + jobMock, machineLearningMock, - moveMock, mediaMock, personMock, - systemMock, - storageMock, - jobMock, searchMock, - cryptoMock, - loggerMock, - ); + storageMock, + systemMock, + } = newTestService(PersonService)); }); it('should be defined', () => { @@ -204,23 +189,6 @@ describe(PersonService.name, () => { }); }); - describe('getAssets', () => { - it('should require person.read permission', async () => { - personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); - await expect(sut.getAssets(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); - expect(personMock.getAssets).not.toHaveBeenCalled(); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); - }); - - it("should return a person's assets", async () => { - personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); - accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await sut.getAssets(authStub.admin, 'person-1'); - expect(personMock.getAssets).toHaveBeenCalledWith('person-1'); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); - }); - }); - describe('update', () => { it('should require person.write permission', async () => { personMock.getById.mockResolvedValue(personStub.noName); @@ -241,19 +209,17 @@ describe(PersonService.name, () => { }); it("should update a person's name", async () => { - personMock.update.mockResolvedValue([personStub.withName]); - personMock.getAssets.mockResolvedValue([assetStub.image]); + personMock.update.mockResolvedValue(personStub.withName); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', name: 'Person 1' }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's date of birth", async () => { - personMock.update.mockResolvedValue([personStub.withBirthDate]); - personMock.getAssets.mockResolvedValue([assetStub.image]); + personMock.update.mockResolvedValue(personStub.withBirthDate); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { birthDate: '1976-06-30' })).resolves.toEqual({ @@ -264,25 +230,24 @@ describe(PersonService.name, () => { isHidden: false, updatedAt: expect.any(Date), }); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', birthDate: '1976-06-30' }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should update a person visibility', async () => { - personMock.update.mockResolvedValue([personStub.withName]); - personMock.getAssets.mockResolvedValue([assetStub.image]); + personMock.update.mockResolvedValue(personStub.withName); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', isHidden: false }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it("should update a person's thumbnailPath", async () => { - personMock.update.mockResolvedValue([personStub.withName]); + personMock.update.mockResolvedValue(personStub.withName); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -291,7 +256,7 @@ describe(PersonService.name, () => { sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), ).resolves.toEqual(responseDto); - expect(personMock.update).toHaveBeenCalledWith([{ id: 'person-1', faceAssetId: faceStub.face1.id }]); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); expect(personMock.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, @@ -441,11 +406,11 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { - personMock.create.mockResolvedValue([personStub.primaryPerson]); + personMock.create.mockResolvedValue(personStub.primaryPerson); await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson); - expect(personMock.create).toHaveBeenCalledWith([{ ownerId: authStub.admin.user.id }]); + expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id }); }); }); @@ -476,7 +441,7 @@ describe(PersonService.name, () => { hasNextPage: false, }); - await sut.handleQueueDetectFaces({}); + await sut.handleQueueDetectFaces({ force: false }); expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); expect(jobMock.queueAll).toHaveBeenCalledWith([ @@ -492,14 +457,33 @@ describe(PersonService.name, () => { items: [assetStub.image], hasNextPage: false, }); - personMock.getAll.mockResolvedValue({ - items: [personStub.withName], + personMock.getAllWithoutFaces.mockResolvedValue([personStub.withName]); + + await sut.handleQueueDetectFaces({ force: true }); + + expect(personMock.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(personMock.delete).toHaveBeenCalledWith([personStub.withName]); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); + expect(assetMock.getAll).toHaveBeenCalled(); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { + name: JobName.FACE_DETECTION, + data: { id: assetStub.image.id }, + }, + ]); + }); + + it('should refresh all assets', async () => { + assetMock.getAll.mockResolvedValue({ + items: [assetStub.image], hasNextPage: false, }); - personMock.getAllWithoutFaces.mockResolvedValue([]); - await sut.handleQueueDetectFaces({ force: true }); + await sut.handleQueueDetectFaces({ force: undefined }); + expect(personMock.delete).not.toHaveBeenCalled(); + expect(personMock.deleteFaces).not.toHaveBeenCalled(); + expect(storageMock.unlink).not.toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { @@ -507,6 +491,7 @@ describe(PersonService.name, () => { data: { id: assetStub.image.id }, }, ]); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); }); it('should delete existing people and faces if forced', async () => { @@ -569,7 +554,7 @@ describe(PersonService.name, () => { expect(personMock.getAllFaces).toHaveBeenCalledWith( { skip: 0, take: 1000 }, - { where: { personId: IsNull(), sourceType: IsNull() } }, + { where: { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING } }, ); expect(jobMock.queueAll).toHaveBeenCalledWith([ { @@ -661,7 +646,7 @@ describe(PersonService.name, () => { expect(systemMock.set).not.toHaveBeenCalled(); }); - it('should delete existing people and faces if forced', async () => { + it('should delete existing people if forced', async () => { jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); personMock.getAll.mockResolvedValue({ items: [faceStub.face1.person, personStub.randomPerson], @@ -676,7 +661,8 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: true }); - expect(personMock.deleteAllFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(personMock.deleteFaces).not.toHaveBeenCalled(); + expect(personMock.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, @@ -689,6 +675,10 @@ describe(PersonService.name, () => { }); describe('handleDetectFaces', () => { + beforeEach(() => { + cryptoMock.randomUUID.mockReturnValue(faceId); + }); + it('should skip if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); @@ -745,27 +735,73 @@ describe(PersonService.name, () => { it('should create a face with no person and queue recognition job', async () => { personMock.createFaces.mockResolvedValue([faceStub.face1.id]); machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); - searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]); assetMock.getByIds.mockResolvedValue([assetStub.image]); - const faceId = 'face-id'; - cryptoMock.randomUUID.mockReturnValue(faceId); - const face = { - id: faceId, - assetId: 'asset-id', - boundingBoxX1: 100, - boundingBoxY1: 100, - boundingBoxX2: 200, - boundingBoxY2: 200, - imageHeight: 500, - imageWidth: 400, - faceSearch: { faceId, embedding: [1, 2, 3, 4] }, - }; await sut.handleDetectFaces({ id: assetStub.image.id }); - expect(personMock.createFaces).toHaveBeenCalledWith([face]); + expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, + { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, + ]); + expect(personMock.reassignFace).not.toHaveBeenCalled(); + expect(personMock.reassignFaces).not.toHaveBeenCalled(); + }); + + it('should delete an existing face not among the new detected faces', async () => { + machineLearningMock.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 }); + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + + await sut.handleDetectFaces({ id: assetStub.image.id }); + + expect(personMock.refreshFaces).toHaveBeenCalledWith([], [faceStub.primaryFace1.id], []); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(personMock.reassignFace).not.toHaveBeenCalled(); + expect(personMock.reassignFaces).not.toHaveBeenCalled(); + }); + + it('should add new face and delete an existing face not among the new detected faces', async () => { + personMock.createFaces.mockResolvedValue([faceStub.face1.id]); + machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); + + await sut.handleDetectFaces({ id: assetStub.image.id }); + + expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, + { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, + ]); + expect(personMock.reassignFace).not.toHaveBeenCalled(); + expect(personMock.reassignFaces).not.toHaveBeenCalled(); + }); + + it('should add embedding to matching metadata face', async () => { + machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif1] }]); + + await sut.handleDetectFaces({ id: assetStub.image.id }); + + expect(personMock.refreshFaces).toHaveBeenCalledWith( + [], + [], + [{ faceId: faceStub.fromExif1.id, embedding: faceSearch.embedding }], + ); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + expect(personMock.reassignFace).not.toHaveBeenCalled(); + expect(personMock.reassignFaces).not.toHaveBeenCalled(); + }); + + it('should not add embedding to non-matching metadata face', async () => { + machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.fromExif2] }]); + + await sut.handleDetectFaces({ id: assetStub.image.id }); + + expect(personMock.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.face1.id } }, + { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, + { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, ]); expect(personMock.reassignFace).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled(); @@ -819,7 +855,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([faceStub.primaryFace1.person]); + personMock.create.mockResolvedValue(faceStub.primaryFace1.person); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -844,16 +880,14 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); - expect(personMock.create).toHaveBeenCalledWith([ - { - ownerId: faceStub.noPerson1.asset.ownerId, - faceAssetId: faceStub.noPerson1.id, - }, - ]); + expect(personMock.create).toHaveBeenCalledWith({ + ownerId: faceStub.noPerson1.asset.ownerId, + faceAssetId: faceStub.noPerson1.id, + }); expect(personMock.reassignFaces).toHaveBeenCalledWith({ faceIds: [faceStub.noPerson1.id], newPersonId: personStub.withName.id, @@ -865,7 +899,7 @@ describe(PersonService.name, () => { searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -884,7 +918,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); @@ -906,7 +940,7 @@ describe(PersonService.name, () => { systemMock.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); - personMock.create.mockResolvedValue([personStub.withName]); + personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); @@ -964,12 +998,11 @@ describe(PersonService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 238, top: 163, @@ -978,13 +1011,12 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); - expect(personMock.update).toHaveBeenCalledWith([ - { - id: 'person-1', - thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - }, - ]); + expect(personMock.update).toHaveBeenCalledWith({ + id: 'person-1', + thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + }); }); it('should generate a thumbnail without going negative', async () => { @@ -995,13 +1027,12 @@ describe(PersonService.name, () => { await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - assetStub.image.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + assetStub.primaryImage.originalPath, { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 0, top: 85, @@ -1010,6 +1041,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, + 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', ); }); @@ -1022,12 +1054,11 @@ describe(PersonService.name, () => { expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( assetStub.primaryImage.originalPath, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', { - format: 'jpeg', + colorspace: Colorspace.P3, + format: ImageFormat.JPEG, size: 250, quality: 80, - colorspace: Colorspace.P3, crop: { left: 591, top: 591, @@ -1036,33 +1067,7 @@ describe(PersonService.name, () => { }, processInvalidImages: false, }, - ); - }); - - it('should use preview path for videos', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); - assetMock.getById.mockResolvedValue(assetStub.video); - mediaMock.getImageDimensions.mockResolvedValue({ width: 2560, height: 1440 }); - - await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id }); - - expect(mediaMock.generateThumbnail).toHaveBeenCalledWith( - '/uploads/user-id/thumbs/path.jpg', 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - { - format: 'jpeg', - size: 250, - quality: 80, - colorspace: Colorspace.P3, - crop: { - left: 1741, - top: 851, - width: 588, - height: 588, - }, - processInvalidImages: false, - }, ); }); }); @@ -1103,7 +1108,7 @@ describe(PersonService.name, () => { it('should merge two people with smart merge', async () => { personMock.getById.mockResolvedValueOnce(personStub.randomPerson); personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); - personMock.update.mockResolvedValue([{ ...personStub.randomPerson, name: personStub.primaryPerson.name }]); + personMock.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name }); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); @@ -1116,12 +1121,10 @@ describe(PersonService.name, () => { oldPersonId: personStub.primaryPerson.id, }); - expect(personMock.update).toHaveBeenCalledWith([ - { - id: personStub.randomPerson.id, - name: personStub.primaryPerson.name, - }, - ]); + expect(personMock.update).toHaveBeenCalledWith({ + id: personStub.randomPerson.id, + name: personStub.primaryPerson.name, + }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index c4b5df5719352..624fb46b6d68b 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,10 +1,7 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { ImageFormat } from 'src/config'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceResponseDto, @@ -23,17 +20,22 @@ import { } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { PersonPathType } from 'src/entities/move.entity'; +import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, Permission, SourceType, SystemMetadataKey } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { + AssetType, + CacheControl, + ImageFormat, + Permission, + PersonPathType, + SourceType, + SystemMetadataKey, +} from 'src/enum'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IBaseJob, IDeferrableJob, IEntityJob, - IJobRepository, INightlyJob, JOBS_ASSET_PAGINATION_SIZE, JobItem, @@ -41,54 +43,20 @@ import { JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { CropOptions, IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BoundingBox } from 'src/interfaces/machine-learning.interface'; +import { CropOptions, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; +import { UpdateFacesData } from 'src/interfaces/person.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; import { IsNull } from 'typeorm'; @Injectable() -export class PersonService { - private configCore: SystemConfigCore; - private storageCore: StorageCore; - - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IMediaRepository) private mediaRepository: IMediaRepository, - @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(PersonService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - repository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - +export class PersonService extends BaseService { async getAll(auth: AuthDto, dto: PersonSearchDto): Promise { const { withHidden = false, page, size } = dto; const pagination = { @@ -96,12 +64,12 @@ export class PersonService { skip: (page - 1) * size, }; - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); - const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, { + const { machineLearning } = await this.getConfig({ withCache: false }); + const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden, }); - const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id); + const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id); return { people: items.map((person) => mapPerson(person)), @@ -112,15 +80,15 @@ export class PersonService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); const person = await this.findOrFail(personId); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; for (const data of dto.data) { - const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); + const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); for (const face of faces) { - await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); if (person.faceAssetId === null) { changeFeaturePhoto.push(person.id); } @@ -128,7 +96,7 @@ export class PersonService { changeFeaturePhoto.push(face.person.id); } - await this.repository.reassignFace(face.id, personId); + await this.personRepository.reassignFace(face.id, personId); } result.push(person); @@ -141,12 +109,12 @@ export class PersonService { } async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); - await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); - const face = await this.repository.getFaceById(dto.id); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); + const face = await this.personRepository.getFaceById(dto.id); const person = await this.findOrFail(personId); - await this.repository.reassignFace(face.id, personId); + await this.personRepository.reassignFace(face.id, personId); if (person.faceAssetId === null) { await this.createNewFeaturePhoto([person.id]); } @@ -158,8 +126,8 @@ export class PersonService { } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [dto.id] }); - const faces = await this.repository.getFaces(dto.id); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [dto.id] }); + const faces = await this.personRepository.getFaces(dto.id); return faces.map((asset) => mapFaces(asset, auth)); } @@ -170,10 +138,10 @@ export class PersonService { const jobs: JobItem[] = []; for (const personId of changeFeaturePhoto) { - const assetFace = await this.repository.getRandomFace(personId); + const assetFace = await this.personRepository.getRandomFace(personId); if (assetFace !== null) { - await this.repository.update([{ id: personId, faceAssetId: assetFace.id }]); + await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); } } @@ -182,18 +150,18 @@ export class PersonService { } async getById(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); return this.findOrFail(id).then(mapPerson); } async getStatistics(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); - return this.repository.getStatistics(id); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); + return this.personRepository.getStatistics(id); } async getThumbnail(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); - const person = await this.repository.getById(id); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); + const person = await this.personRepository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); } @@ -205,33 +173,24 @@ export class PersonService { }); } - async getAssets(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); - const assets = await this.repository.getAssets(id); - return assets.map((asset) => mapAsset(asset)); - } - - async create(auth: AuthDto, dto: PersonCreateDto): Promise { - const [created] = await this.repository.create([ - { - ownerId: auth.user.id, - name: dto.name, - birthDate: dto.birthDate, - isHidden: dto.isHidden, - }, - ]); - return created; + create(auth: AuthDto, dto: PersonCreateDto): Promise { + return this.personRepository.create({ + ownerId: auth.user.id, + name: dto.name, + birthDate: dto.birthDate, + isHidden: dto.isHidden, + }); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { - await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [assetId] }); - const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [assetId] }); + const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); if (!face) { throw new BadRequestException('Invalid assetId for feature face'); } @@ -239,7 +198,7 @@ export class PersonService { faceId = face.id; } - const [person] = await this.repository.update([{ id, faceAssetId: faceId, name, birthDate, isHidden }]); + const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); @@ -269,46 +228,36 @@ export class PersonService { private async delete(people: PersonEntity[]) { await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); - await this.repository.delete(people); + await this.personRepository.delete(people); this.logger.debug(`Deleted ${people.length} people`); } - private async deleteAllPeople() { - const personPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAll({ ...pagination, skip: 0 }), - ); - - for await (const people of personPagination) { - await this.delete(people); // deletes thumbnails too - } - } - async handlePersonCleanup(): Promise { - const people = await this.repository.getAllWithoutFaces(); + const people = await this.personRepository.getAllWithoutFaces(); await this.delete(people); return JobStatus.SUCCESS; } async handleQueueDetectFaces({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { - return force - ? this.assetRepository.getAll(pagination, { + return force === false + ? this.assetRepository.getWithout(pagination, WithoutProperty.FACES) + : this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true, withArchived: true, isVisible: true, - }) - : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); + }); }); for await (const assets of assetPagination) { @@ -317,11 +266,15 @@ export class PersonService { ); } + if (force === undefined) { + await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); + } + return JobStatus.SUCCESS; } async handleDetectFaces({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -335,11 +288,11 @@ export class PersonService { }; const [asset] = await this.assetRepository.getByIds([id], relations); const { previewFile } = getAssetFiles(asset.files); - if (!asset || !previewFile || asset.faces?.length > 0) { + if (!asset || !previewFile) { return JobStatus.FAILED; } - if (!asset.isVisible || asset.faces.length > 0) { + if (!asset.isVisible) { return JobStatus.SKIPPED; } @@ -348,41 +301,84 @@ export class PersonService { previewFile.path, machineLearning.facialRecognition, ); - this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`); - if (faces.length > 0) { - await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); - const mappedFaces: Partial[] = []; - for (const face of faces) { + const facesToAdd: (Partial & { id: string })[] = []; + const embeddings: FaceSearchEntity[] = []; + const mlFaceIds = new Set(); + for (const face of asset.faces) { + if (face.sourceType === SourceType.MACHINE_LEARNING) { + mlFaceIds.add(face.id); + } + } + + const heightScale = imageHeight / (asset.faces[0]?.imageHeight || 1); + const widthScale = imageWidth / (asset.faces[0]?.imageWidth || 1); + for (const { boundingBox, embedding } of faces) { + const scaledBox = { + x1: boundingBox.x1 * widthScale, + y1: boundingBox.y1 * heightScale, + x2: boundingBox.x2 * widthScale, + y2: boundingBox.y2 * heightScale, + }; + const match = asset.faces.find((face) => this.iou(face, scaledBox) > 0.5); + + if (match && !mlFaceIds.delete(match.id)) { + embeddings.push({ faceId: match.id, embedding }); + } else if (!match) { const faceId = this.cryptoRepository.randomUUID(); - mappedFaces.push({ + facesToAdd.push({ id: faceId, assetId: asset.id, imageHeight, imageWidth, - boundingBoxX1: face.boundingBox.x1, - boundingBoxY1: face.boundingBox.y1, - boundingBoxX2: face.boundingBox.x2, - boundingBoxY2: face.boundingBox.y2, - faceSearch: { faceId, embedding: face.embedding }, + boundingBoxX1: boundingBox.x1, + boundingBoxY1: boundingBox.y1, + boundingBoxX2: boundingBox.x2, + boundingBoxY2: boundingBox.y2, }); + embeddings.push({ faceId, embedding }); } + } + const faceIdsToRemove = [...mlFaceIds]; - const faceIds = await this.repository.createFaces(mappedFaces); - await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } }))); + if (facesToAdd.length > 0 || faceIdsToRemove.length > 0 || embeddings.length > 0) { + await this.personRepository.refreshFaces(facesToAdd, faceIdsToRemove, embeddings); } - await this.assetRepository.upsertJobStatus({ - assetId: asset.id, - facesRecognizedAt: new Date(), - }); + if (faceIdsToRemove.length > 0) { + this.logger.log(`Removed ${faceIdsToRemove.length} faces below detection threshold in asset ${id}`); + } + + if (facesToAdd.length > 0) { + this.logger.log(`Detected ${facesToAdd.length} new faces in asset ${id}`); + const jobs = facesToAdd.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id } }) as const); + await this.jobRepository.queueAll([{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, ...jobs]); + } else if (embeddings.length > 0) { + this.logger.log(`Added ${embeddings.length} face embeddings for asset ${id}`); + } + + await this.assetRepository.upsertJobStatus({ assetId: asset.id, facesRecognizedAt: new Date() }); return JobStatus.SUCCESS; } + private iou(face: AssetFaceEntity, newBox: BoundingBox): number { + const x1 = Math.max(face.boundingBoxX1, newBox.x1); + const y1 = Math.max(face.boundingBoxY1, newBox.y1); + const x2 = Math.min(face.boundingBoxX2, newBox.x2); + const y2 = Math.min(face.boundingBoxY2, newBox.y2); + + const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1); + const area1 = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1); + const area2 = (newBox.x2 - newBox.x1) * (newBox.y2 - newBox.y1); + const union = area1 + area2 - intersection; + + return intersection / union; + } + async handleQueueRecognizeFaces({ force, nightly }: INightlyJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -392,7 +388,7 @@ export class PersonService { if (nightly) { const [state, latestFaceDate] = await Promise.all([ this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE), - this.repository.getLatestFaceDate(), + this.personRepository.getLatestFaceDate(), ]); if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) { @@ -404,7 +400,7 @@ export class PersonService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.repository.deleteAllFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( @@ -415,8 +411,8 @@ export class PersonService { const lastRun = new Date().toISOString(); const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAllFaces(pagination, { - where: force ? undefined : { personId: IsNull(), sourceType: IsNull() }, + this.personRepository.getAllFaces(pagination, { + where: force ? undefined : { personId: IsNull(), sourceType: SourceType.MACHINE_LEARNING }, }), ); @@ -432,12 +428,12 @@ export class PersonService { } async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.SKIPPED; } - const face = await this.repository.getFaceByIdWithAssets( + const face = await this.personRepository.getFaceByIdWithAssets( id, { person: true, asset: true, faceSearch: true }, { id: true, personId: true, sourceType: true, faceSearch: { embedding: true } }, @@ -462,7 +458,7 @@ export class PersonService { return JobStatus.SKIPPED; } - const matches = await this.smartInfoRepository.searchFaces({ + const matches = await this.searchRepository.searchFaces({ userIds: [face.asset.ownerId], embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, @@ -486,7 +482,7 @@ export class PersonService { let personId = matches.find((match) => match.face.personId)?.face.personId; if (!personId) { - const matchWithPerson = await this.smartInfoRepository.searchFaces({ + const matchWithPerson = await this.searchRepository.searchFaces({ userIds: [face.asset.ownerId], embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, @@ -501,21 +497,21 @@ export class PersonService { if (isCore && !personId) { this.logger.log(`Creating new person for face ${id}`); - const [newPerson] = await this.repository.create([{ ownerId: face.asset.ownerId, faceAssetId: face.id }]); + const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); personId = newPerson.id; } if (personId) { this.logger.debug(`Assigning face ${id} to person ${personId}`); - await this.repository.reassignFaces({ faceIds: [id], newPersonId: personId }); + await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); } return JobStatus.SUCCESS; } async handlePersonMigration({ id }: IEntityJob): Promise { - const person = await this.repository.getById(id); + const person = await this.personRepository.getById(id); if (!person) { return JobStatus.FAILED; } @@ -526,18 +522,18 @@ export class PersonService { } async handleGeneratePersonThumbnail(data: IEntityJob): Promise { - const { machineLearning, metadata, image } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { return JobStatus.SKIPPED; } - const person = await this.repository.getById(data.id); + const person = await this.personRepository.getById(data.id); if (!person?.faceAssetId) { this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`); return JobStatus.FAILED; } - const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId); + const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId); if (face === null) { this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`); return JobStatus.FAILED; @@ -568,16 +564,16 @@ export class PersonService { this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions = { + colorspace: image.colorspace, format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, - colorspace: image.colorspace, - quality: image.quality, + quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', - } as const; + }; - await this.mediaRepository.generateThumbnail(inputPath, thumbnailPath, thumbnailOptions); - await this.repository.update([{ id: person.id, thumbnailPath }]); + await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); + await this.personRepository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; } @@ -588,13 +584,13 @@ export class PersonService { throw new BadRequestException('Cannot merge a person into themselves'); } - await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); let primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; const results: BulkIdResponseDto[] = []; - const allowedIds = await checkAccess(this.access, { + const allowedIds = await checkAccess(this.accessRepository, { auth, permission: Permission.PERSON_MERGE, ids: mergeIds, @@ -608,7 +604,7 @@ export class PersonService { } try { - const mergePerson = await this.repository.getById(mergeId); + const mergePerson = await this.personRepository.getById(mergeId); if (!mergePerson) { results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); continue; @@ -624,14 +620,14 @@ export class PersonService { } if (Object.keys(update).length > 0) { - [primaryPerson] = await this.repository.update([{ id: primaryPerson.id, ...update }]); + primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update }); } const mergeName = mergePerson.name || mergePerson.id; const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; this.logger.log(`Merging ${mergeName} into ${primaryName}`); - await this.repository.reassignFaces(mergeData); + await this.personRepository.reassignFaces(mergeData); await this.delete([mergePerson]); this.logger.log(`Merged ${mergeName} into ${primaryName}`); @@ -645,7 +641,7 @@ export class PersonService { } private async findOrFail(id: string) { - const person = await this.repository.getById(id); + const person = await this.personRepository.getById(id); if (!person) { throw new BadRequestException('Person not found'); } diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index ded087b8b5dc4..e0b03f31aee3b 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,60 +1,26 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMetadataRepository } from 'src/interfaces/metadata.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SearchService } from 'src/services/search.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, beforeEach, vitest } from 'vitest'; vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; + let assetMock: Mocked; - let systemMock: Mocked; - let machineMock: Mocked; let personMock: Mocked; let searchMock: Mocked; - let partnerMock: Mocked; - let metadataMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - machineMock = newMachineLearningRepositoryMock(); - personMock = newPersonRepositoryMock(); - searchMock = newSearchRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - metadataMock = newMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new SearchService( - systemMock, - machineMock, - personMock, - searchMock, - assetMock, - partnerMock, - metadataMock, - loggerMock, - ); + ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); }); it('should work', () => { @@ -99,19 +65,19 @@ describe(SearchService.name, () => { describe('getSearchSuggestions', () => { it('should return search suggestions (including null)', async () => { - metadataMock.getCountries.mockResolvedValue(['USA', null]); + searchMock.getCountries.mockResolvedValue(['USA', null]); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA', null]); - expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions (without null)', async () => { - metadataMock.getCountries.mockResolvedValue(['USA', null]); + searchMock.getCountries.mockResolvedValue(['USA', null]); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA']); - expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 4c86d4ad75ebe..03ffbe97db14e 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,11 +1,11 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, + RandomSearchDto, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -16,35 +16,13 @@ import { } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetOrder } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMetadataRepository } from 'src/interfaces/metadata.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { SearchExploreItem } from 'src/interfaces/search.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class SearchService { - private configCore: SystemConfigCore; - - constructor( - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, - @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IMetadataRepository) private metadataRepository: IMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(SearchService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); - } - +export class SearchService extends BaseService { async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); } @@ -95,15 +73,25 @@ export class SearchService { return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth }); } + async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { + const userIds = await this.getUserIdsToSearch(auth); + const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); + } + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { throw new BadRequestException('Smart search is not enabled'); } const userIds = await this.getUserIdsToSearch(auth); - const embedding = await this.machineLearning.encodeText(machineLearning.url, dto.query, machineLearning.clip); + const embedding = await this.machineLearningRepository.encodeText( + machineLearning.url, + dto.query, + machineLearning.clip, + ); const page = dto.page ?? 1; const size = dto.size || 100; const { hasNextPage, items } = await this.searchRepository.searchSmart( @@ -129,19 +117,19 @@ export class SearchService { private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { switch (dto.type) { case SearchSuggestionType.COUNTRY: { - return this.metadataRepository.getCountries(userIds); + return this.searchRepository.getCountries(userIds); } case SearchSuggestionType.STATE: { - return this.metadataRepository.getStates(userIds, dto.country); + return this.searchRepository.getStates(userIds, dto.country); } case SearchSuggestionType.CITY: { - return this.metadataRepository.getCities(userIds, dto.country, dto.state); + return this.searchRepository.getCities(userIds, dto.country, dto.state); } case SearchSuggestionType.CAMERA_MAKE: { - return this.metadataRepository.getCameraMakes(userIds, dto.model); + return this.searchRepository.getCameraMakes(userIds, dto.model); } case SearchSuggestionType.CAMERA_MODEL: { - return this.metadataRepository.getCameraModels(userIds, dto.make); + return this.searchRepository.getCameraModels(userIds, dto.make); } default: { return []; diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index ac899f7b13ba8..ab6eb3b1a4f05 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,37 +1,20 @@ import { SystemMetadataKey } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { ServerService } from 'src/services/server.service'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(ServerService.name, () => { let sut: ServerService; + let storageMock: Mocked; - let userMock: Mocked; - let serverInfoMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; - let cryptoMock: Mocked; + let userMock: Mocked; beforeEach(() => { - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - serverInfoMock = newServerInfoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - - sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock); + ({ sut, storageMock, systemMock, userMock } = newTestService(ServerService)); }); it('should work', () => { @@ -176,9 +159,9 @@ describe(ServerService.name, () => { }); }); - describe('getConfig', () => { + describe('getSystemConfig', () => { it('should respond the server configuration', async () => { - await expect(sut.getConfig()).resolves.toEqual({ + await expect(sut.getSystemConfig()).resolves.toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', trashDays: 30, @@ -186,6 +169,8 @@ describe(ServerService.name, () => { isInitialized: undefined, isOnboarded: false, externalDomain: '', + mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', + mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', }); expect(systemMock.get).toHaveBeenCalled(); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e57a206765f96..3fc319a2fd938 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -1,9 +1,7 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { serverVersion } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, @@ -15,34 +13,16 @@ import { ServerStorageResponseDto, UsageByUserDto, } from 'src/dtos/server.dto'; -import { SystemMetadataKey } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { StorageFolder, SystemMetadataKey } from 'src/enum'; +import { UserStatsQueryResponse } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - ) { - this.logger.setContext(ServerService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - @OnEmit({ event: 'app.bootstrap' }) +export class ServerService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { @@ -55,7 +35,7 @@ export class ServerService { async getAboutInfo(): Promise { const version = `v${serverVersion.toString()}`; - const buildMetadata = getBuildMetadata(); + const { buildMetadata } = this.configRepository.getEnv(); const buildVersions = await this.serverInfoRepository.getBuildVersions(); const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); @@ -91,7 +71,8 @@ export class ServerService { async getFeatures(): Promise { const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } = - await this.configCore.getConfig({ withCache: false }); + await this.getConfig({ withCache: false }); + const { configFile } = this.configRepository.getEnv(); return { smartSearch: isSmartSearchEnabled(machineLearning), @@ -106,18 +87,18 @@ export class ServerService { oauth: oauth.enabled, oauthAutoLaunch: oauth.autoLaunch, passwordLogin: passwordLogin.enabled, - configFile: this.configCore.isUsingConfigFile(), + configFile: !!configFile, email: notifications.smtp.enabled, }; } async getTheme() { - const { theme } = await this.configCore.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme; } - async getConfig(): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); const isInitialized = await this.userRepository.hasAdmin(); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); @@ -129,6 +110,8 @@ export class ServerService { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + mapDarkStyleUrl: config.map.darkStyle, + mapLightStyleUrl: config.map.lightStyle, }; } @@ -178,20 +161,13 @@ export class ServerService { if (!dto.licenseKey.startsWith('IMSV-')) { throw new BadRequestException('Invalid license key'); } - const licenseValid = this.cryptoRepository.verifySha256( - dto.licenseKey, - dto.activationKey, - getServerLicensePublicKey(), - ); - + const { licensePublicKey } = this.configRepository.getEnv(); + const licenseValid = this.cryptoRepository.verifySha256(dto.licenseKey, dto.activationKey, licensePublicKey.server); if (!licenseValid) { throw new BadRequestException('Invalid license key'); } - const licenseData = { - ...dto, - activatedAt: new Date(), - }; + const licenseData = { ...dto, activatedAt: new Date() }; await this.systemMetadataRepository.set(SystemMetadataKey.LICENSE, licenseData); diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index ca3d2fd858fb0..49d122771210d 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,27 +1,21 @@ import { UserEntity } from 'src/entities/user.entity'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { SessionService } from 'src/services/session.service'; import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe('SessionService', () => { let sut: SessionService; + let accessMock: Mocked; - let loggerMock: Mocked; let sessionMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sessionMock = newSessionRepositoryMock(); - - sut = new SessionService(accessMock, loggerMock, sessionMock); + ({ sut, accessMock, sessionMock } = newTestService(SessionService)); }); it('should be defined', () => { diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 47abf3c380246..c68fb3088c9aa 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,24 +1,14 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISessionRepository } from 'src/interfaces/session.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; @Injectable() -export class SessionService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ISessionRepository) private sessionRepository: ISessionRepository, - ) { - this.logger.setContext(SessionService.name); - } - +export class SessionService extends BaseService { async handleCleanup() { const sessions = await this.sessionRepository.search({ updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(), @@ -44,7 +34,7 @@ export class SessionService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); await this.sessionRepository.delete(id); } diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 0fd47b612e77e..d0959f31b8f4a 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -3,38 +3,24 @@ import _ from 'lodash'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { SharedLinkType } from 'src/enum'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SharedLinkService } from 'src/services/shared-link.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(SharedLinkService.name, () => { let sut: SharedLinkService; + let accessMock: IAccessRepositoryMock; - let cryptoMock: Mocked; - let shareMock: Mocked; - let systemMock: Mocked; - let logMock: Mocked; + let sharedLinkMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - shareMock = newSharedLinkRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - logMock = newLoggerRepositoryMock(); - - sut = new SharedLinkService(accessMock, cryptoMock, logMock, shareMock, systemMock); + ({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService)); }); it('should work', () => { @@ -43,55 +29,64 @@ describe(SharedLinkService.name, () => { describe('getAll', () => { it('should return all shared links for a user', async () => { - shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); + sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); await expect(sut.getAll(authStub.user1)).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); - expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); + expect(sharedLinkMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); }); }); describe('getMine', () => { it('should only work for a public user', async () => { await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); - expect(shareMock.get).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).not.toHaveBeenCalled(); }); it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); - expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should not return metadata', async () => { const authDto = authStub.adminSharedLinkNoExif; - shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); - expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); - it('should throw an error for an password protected shared link', async () => { + it('should throw an error for an invalid password protected shared link', async () => { const authDto = authStub.adminSharedLink; - shareMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired); await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); - expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); + }); + + it('should allow a correct password on a password protected shared link', async () => { + sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); + await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); + expect(sharedLinkMock.get).toHaveBeenCalledWith( + authStub.adminSharedLink.user.id, + authStub.adminSharedLink.sharedLink?.id, + ); }); }); describe('get', () => { it('should throw an error for an invalid shared link', async () => { - shareMock.get.mockResolvedValue(null); + sharedLinkMock.get.mockResolvedValue(null); await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(shareMock.update).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(sharedLinkMock.update).not.toHaveBeenCalled(); }); it('should get a shared link by id', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); }); }); @@ -122,7 +117,7 @@ describe(SharedLinkService.name, () => { it('should create an album shared link', async () => { accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); - shareMock.create.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid); await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); @@ -130,7 +125,7 @@ describe(SharedLinkService.name, () => { authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); - expect(shareMock.create).toHaveBeenCalledWith({ + expect(sharedLinkMock.create).toHaveBeenCalledWith({ type: SharedLinkType.ALBUM, userId: authStub.admin.user.id, albumId: albumStub.oneAsset.id, @@ -146,7 +141,7 @@ describe(SharedLinkService.name, () => { it('should create an individual shared link', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -160,7 +155,7 @@ describe(SharedLinkService.name, () => { authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(shareMock.create).toHaveBeenCalledWith({ + expect(sharedLinkMock.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -176,7 +171,7 @@ describe(SharedLinkService.name, () => { it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id])); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, @@ -190,7 +185,7 @@ describe(SharedLinkService.name, () => { authStub.admin.user.id, new Set([assetStub.image.id]), ); - expect(shareMock.create).toHaveBeenCalledWith({ + expect(sharedLinkMock.create).toHaveBeenCalledWith({ type: SharedLinkType.INDIVIDUAL, userId: authStub.admin.user.id, albumId: null, @@ -207,18 +202,18 @@ describe(SharedLinkService.name, () => { describe('update', () => { it('should throw an error for an invalid shared link', async () => { - shareMock.get.mockResolvedValue(null); + sharedLinkMock.get.mockResolvedValue(null); await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(shareMock.update).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(sharedLinkMock.update).not.toHaveBeenCalled(); }); it('should update a shared link', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); - shareMock.update.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(shareMock.update).toHaveBeenCalledWith({ + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(sharedLinkMock.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, userId: authStub.user1.user.id, allowDownload: false, @@ -228,31 +223,31 @@ describe(SharedLinkService.name, () => { describe('remove', () => { it('should throw an error for an invalid shared link', async () => { - shareMock.get.mockResolvedValue(null); + sharedLinkMock.get.mockResolvedValue(null); await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); - expect(shareMock.update).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); + expect(sharedLinkMock.update).not.toHaveBeenCalled(); }); it('should remove a key', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await sut.remove(authStub.user1, sharedLinkStub.valid.id); - expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); - expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); + expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); + expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should add assets to a shared link', async () => { - shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3'])); await expect( @@ -264,7 +259,7 @@ describe(SharedLinkService.name, () => { ]); expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); - expect(shareMock.update).toHaveBeenCalledWith({ + expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [assetStub.image, { id: 'asset-3' }], }); @@ -273,15 +268,15 @@ describe(SharedLinkService.name, () => { describe('removeAssets', () => { it('should not work on album shared links', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.valid); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should remove assets from a shared link', async () => { - shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); - shareMock.create.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual)); + sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual); await expect( sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }), @@ -290,29 +285,39 @@ describe(SharedLinkService.name, () => { { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); - expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); + expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] }); }); }); describe('getMetadataTags', () => { it('should return null when auth is not a shared link', async () => { await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); - expect(shareMock.get).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).not.toHaveBeenCalled(); }); it('should return null when shared link has a password', async () => { await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null); - expect(shareMock.get).not.toHaveBeenCalled(); + expect(sharedLinkMock.get).not.toHaveBeenCalled(); }); it('should return metadata tags', async () => { - shareMock.get.mockResolvedValue(sharedLinkStub.individual); + sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); - expect(shareMock.get).toHaveBeenCalled(); + expect(sharedLinkMock.get).toHaveBeenCalled(); + }); + + it('should return metadata tags with a default image path if the asset id is not set', async () => { + sharedLinkMock.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] }); + await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ + description: '0 shared photos & videos', + imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/feature-panel.png`, + title: 'Public Share', + }); + expect(sharedLinkMock.get).toHaveBeenCalled(); }); }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 54c7fdf25bed7..5676531e5741f 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,6 +1,5 @@ -import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -15,31 +14,14 @@ import { import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { Permission, SharedLinkType } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { OpenGraphTags } from 'src/utils/misc'; @Injectable() -export class SharedLinkService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - ) { - this.logger.setContext(SharedLinkService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - getAll(auth: AuthDto): Promise { - return this.repository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); +export class SharedLinkService extends BaseService { + async getAll(auth: AuthDto): Promise { + return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); } async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise { @@ -67,7 +49,7 @@ export class SharedLinkService { if (!dto.albumId) { throw new BadRequestException('Invalid albumId'); } - await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); break; } @@ -76,13 +58,13 @@ export class SharedLinkService { throw new BadRequestException('Invalid assetIds'); } - await requireAccess(this.access, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); break; } } - const sharedLink = await this.repository.create({ + const sharedLink = await this.sharedLinkRepository.create({ key: this.cryptoRepository.randomBytes(50), userId: auth.user.id, type: dto.type, @@ -101,7 +83,7 @@ export class SharedLinkService { async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) { await this.findOrFail(auth.user.id, id); - const sharedLink = await this.repository.update({ + const sharedLink = await this.sharedLinkRepository.update({ id, userId: auth.user.id, description: dto.description, @@ -116,12 +98,12 @@ export class SharedLinkService { async remove(auth: AuthDto, id: string): Promise { const sharedLink = await this.findOrFail(auth.user.id, id); - await this.repository.remove(sharedLink); + await this.sharedLinkRepository.remove(sharedLink); } // TODO: replace `userId` with permissions and access control checks private async findOrFail(userId: string, id: string) { - const sharedLink = await this.repository.get(userId, id); + const sharedLink = await this.sharedLinkRepository.get(userId, id); if (!sharedLink) { throw new BadRequestException('Shared link not found'); } @@ -137,7 +119,7 @@ export class SharedLinkService { const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id)); const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); - const allowedAssetIds = await checkAccess(this.access, { + const allowedAssetIds = await checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: notPresentAssetIds, @@ -161,7 +143,7 @@ export class SharedLinkService { sharedLink.assets.push({ id: assetId } as AssetEntity); } - await this.repository.update(sharedLink); + await this.sharedLinkRepository.update(sharedLink); return results; } @@ -185,7 +167,7 @@ export class SharedLinkService { sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId); } - await this.repository.update(sharedLink); + await this.sharedLinkRepository.update(sharedLink); return results; } @@ -195,7 +177,7 @@ export class SharedLinkService { return null; } - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id); const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets.length || 0; diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 97d22da9b8a4f..f53822a9e2251 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,8 +1,8 @@ import { SystemConfig } from 'src/config'; +import { ImmichWorker } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -10,34 +10,22 @@ import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; import { assetStub } from 'test/fixtures/asset.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(SmartInfoService.name, () => { let sut: SmartInfoService; + let assetMock: Mocked; - let systemMock: Mocked; + let databaseMock: Mocked; let jobMock: Mocked; + let machineLearningMock: Mocked; let searchMock: Mocked; - let machineMock: Mocked; - let databaseMock: Mocked; - let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - searchMock = newSearchRepositoryMock(); - jobMock = newJobRepositoryMock(); - machineMock = newMachineLearningRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, systemMock, loggerMock); + ({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock } = + newTestService(SmartInfoService)); assetMock.getByIds.mockResolvedValue([assetStub.image]); }); @@ -77,7 +65,7 @@ describe(SmartInfoService.name, () => { describe('onBootstrapEvent', () => { it('should return if not microservices', async () => { - await sut.onBootstrap('api'); + await sut.onBootstrap(ImmichWorker.API); expect(systemMock.get).not.toHaveBeenCalled(); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -92,7 +80,7 @@ describe(SmartInfoService.name, () => { it('should return if machine learning is disabled', async () => { systemMock.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await sut.onBootstrap('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).not.toHaveBeenCalled(); @@ -107,7 +95,7 @@ describe(SmartInfoService.name, () => { it('should return if model and DB dimension size are equal', async () => { searchMock.getDimensionSize.mockResolvedValue(512); - await sut.onBootstrap('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -123,7 +111,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.onBootstrap('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -138,7 +126,7 @@ describe(SmartInfoService.name, () => { searchMock.getDimensionSize.mockResolvedValue(768); jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: true }); - await sut.onBootstrap('microservices'); + await sut.onBootstrap(ImmichWorker.MICROSERVICES); expect(systemMock.get).toHaveBeenCalledTimes(1); expect(searchMock.getDimensionSize).toHaveBeenCalledTimes(1); @@ -299,7 +287,7 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); expect(assetMock.getByIds).not.toHaveBeenCalled(); - expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); }); it('should skip assets without a resize path', async () => { @@ -308,15 +296,15 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); expect(searchMock.upsert).not.toHaveBeenCalled(); - expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { - machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); + machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); - expect(machineMock.encodeImage).toHaveBeenCalledWith( + expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( 'http://immich-machine-learning:3003', '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ modelName: 'ViT-B-32__openai' }), @@ -329,9 +317,33 @@ describe(SmartInfoService.name, () => { expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); - expect(machineMock.encodeImage).not.toHaveBeenCalled(); + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); + expect(searchMock.upsert).not.toHaveBeenCalled(); + }); + + it('should fail if asset could not be found', async () => { + assetMock.getByIds.mockResolvedValue([]); + + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED); + + expect(machineLearningMock.encodeImage).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled(); }); + + it('should wait for database', async () => { + machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); + databaseMock.isBusy.mockReturnValue(true); + + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); + + expect(databaseMock.wait).toHaveBeenCalledWith(512); + expect(machineLearningMock.encodeImage).toHaveBeenCalledWith( + 'http://immich-machine-learning:3003', + '/uploads/user-id/thumbs/path.jpg', + expect.objectContaining({ modelName: 'ViT-B-32__openai' }), + ); + expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]); + }); }); describe('getCLIPModelInfo', () => { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index a75594100f231..778f40c931618 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,55 +1,41 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { OnEvent } from 'src/decorators'; +import { ImmichWorker } from 'src/enum'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; +import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { IBaseJob, IEntityJob, - IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus, QueueName, } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { ISearchRepository } from 'src/interfaces/search.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; import { getAssetFiles } from 'src/utils/asset.util'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @Injectable() -export class SmartInfoService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, - @Inject(ISearchRepository) private repository: ISearchRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(SmartInfoService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - @OnEmit({ event: 'app.bootstrap' }) +export class SmartInfoService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { - if (app !== 'microservices') { + if (app !== ImmichWorker.MICROSERVICES) { return; } - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.init(config); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update' }) + async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { + await this.init(newConfig, oldConfig); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { getCLIPModelInfo(newConfig.machineLearning.clip.modelName); @@ -60,11 +46,6 @@ export class SmartInfoService { } } - @OnEmit({ event: 'config.update' }) - async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { - await this.init(newConfig, oldConfig); - } - private async init(newConfig: SystemConfig, oldConfig?: SystemConfig) { if (!isSmartSearchEnabled(newConfig.machineLearning)) { return; @@ -72,7 +53,7 @@ export class SmartInfoService { await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => { const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName); - const dbDimSize = await this.repository.getDimensionSize(); + const dbDimSize = await this.searchRepository.getDimensionSize(); this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`); const modelChange = @@ -93,10 +74,10 @@ export class SmartInfoService { `Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`, ); this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`); - await this.repository.setDimensionSize(dimSize); + await this.searchRepository.setDimensionSize(dimSize); this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`); } else { - await this.repository.deleteAllSearchEmbeddings(); + await this.searchRepository.deleteAllSearchEmbeddings(); } if (!isPaused) { @@ -106,13 +87,13 @@ export class SmartInfoService { } async handleQueueEncodeClip({ force }: IBaseJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: false }); + const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } if (force) { - await this.repository.deleteAllSearchEmbeddings(); + await this.searchRepository.deleteAllSearchEmbeddings(); } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { @@ -131,7 +112,7 @@ export class SmartInfoService { } async handleEncodeClip({ id }: IEntityJob): Promise { - const { machineLearning } = await this.configCore.getConfig({ withCache: true }); + const { machineLearning } = await this.getConfig({ withCache: true }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.SKIPPED; } @@ -150,7 +131,7 @@ export class SmartInfoService { return JobStatus.FAILED; } - const embedding = await this.machineLearning.encodeImage( + const embedding = await this.machineLearningRepository.encodeImage( machineLearning.url, previewFile.path, machineLearning.clip, @@ -161,7 +142,7 @@ export class SmartInfoService { await this.databaseRepository.wait(DatabaseLock.CLIPDimSize); } - await this.repository.upsert(asset.id, embedding); + await this.searchRepository.upsert(asset.id, embedding); return JobStatus.SUCCESS; } diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index bebc8517d6b7a..c965d3e73e885 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -1,21 +1,13 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; @Injectable() -export class StackService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IStackRepository) private stackRepository: IStackRepository, - ) {} - +export class StackService extends BaseService { async search(auth: AuthDto, dto: StackSearchDto): Promise { const stacks = await this.stackRepository.search({ ownerId: auth.user.id, @@ -26,23 +18,23 @@ export class StackService { } async create(auth: AuthDto, dto: StackCreateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stack.create', { stackId: stack.id, userId: auth.user.id }); return mapStack(stack, { auth }); } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.STACK_READ, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_READ, ids: [id] }); const stack = await this.findOrFail(id); return mapStack(stack, { auth }); } async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.STACK_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_UPDATE, ids: [id] }); const stack = await this.findOrFail(id); if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { throw new BadRequestException('Primary asset must be in the stack'); @@ -50,23 +42,21 @@ export class StackService { const updatedStack = await this.stackRepository.update({ id, primaryAssetId: dto.primaryAssetId }); - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stack.update', { stackId: id, userId: auth.user.id }); return mapStack(updatedStack, { auth }); } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: [id] }); await this.stackRepository.delete(id); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id }); } async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids }); + await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: dto.ids }); await this.stackRepository.deleteAll(dto.ids); - - this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, []); + await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id }); } private async findOrFail(id: string) { diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 093cc5b2ff1d6..6e5af3baf9723 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,16 +1,12 @@ import { Stats } from 'node:fs'; import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; +import { AssetPathType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -18,63 +14,31 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; -import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; + let albumMock: Mocked; let assetMock: Mocked; let cryptoMock: Mocked; - let databaseMock: Mocked; let moveMock: Mocked; - let personMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; let userMock: Mocked; - let loggerMock: Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - assetMock = newAssetRepositoryMock(); - albumMock = newAlbumRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); + ({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } = + newTestService(StorageTemplateService)); systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } }); - sut = new StorageTemplateService( - albumMock, - assetMock, - systemMock, - moveMock, - personMock, - storageMock, - userMock, - cryptoMock, - databaseMock, - loggerMock, - ); - - SystemConfigCore.create(systemMock, loggerMock).config$.next(defaults); + sut.onConfigUpdate({ newConfig: defaults }); }); describe('onConfigValidate', () => { @@ -164,13 +128,15 @@ describe(StorageTemplateService.name, () => { originalPath: newMotionPicturePath, }); }); - it('Should use handlebar if condition for album', async () => { + + it('should use handlebar if condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const album = albumStub.oneAsset; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -185,12 +151,13 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); - it('Should use handlebar else condition for album', async () => { + + it('should use handlebar else condition for album', async () => { const asset = assetStub.image; const user = userStub.user1; const config = structuredClone(defaults); config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}'; - SystemConfigCore.create(systemMock, loggerMock).config$.next(config); + sut.onConfigUpdate({ oldConfig: defaults, newConfig: config }); userMock.get.mockResolvedValue(user); assetMock.getByIds.mockResolvedValueOnce([asset]); @@ -205,6 +172,7 @@ describe(StorageTemplateService.name, () => { pathType: AssetPathType.ORIGINAL, }); }); + it('should migrate previously failed move from original path when it still exists', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; @@ -242,6 +210,7 @@ describe(StorageTemplateService.name, () => { originalPath: newPath, }); }); + it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => { userMock.get.mockResolvedValue(userStub.user1); const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${assetStub.image.id}.jpg`; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 9836ad40ace47..e400981f541c5 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -13,24 +12,14 @@ import { supportedWeekTokens, supportedYearTokens, } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit } from 'src/decorators'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; -import { AssetType } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; +import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMoveRepository } from 'src/interfaces/move.interface'; -import { IPersonRepository } from 'src/interfaces/person.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getLivePhotoMotionFilename } from 'src/utils/file'; import { usePagination } from 'src/utils/pagination'; @@ -47,9 +36,7 @@ interface RenderMetadata { } @Injectable() -export class StorageTemplateService { - private configCore: SystemConfigCore; - private storageCore: StorageCore; +export class StorageTemplateService extends BaseService { private _template: { compiled: HandlebarsTemplateDelegate; raw: string; @@ -63,33 +50,16 @@ export class StorageTemplateService { return this._template; } - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(StorageTemplateService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - this.configCore.config$.subscribe((config) => this.onConfig(config)); - this.storageCore = StorageCore.create( - assetRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { + const template = newConfig.storageTemplate.template; + if (!this._template || template !== this.template.raw) { + this.logger.debug(`Compiling new storage template: ${template}`); + this._template = this.compile(template); + } } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { try { const { compiled } = this.compile(newConfig.storageTemplate.template); @@ -111,7 +81,7 @@ export class StorageTemplateService { } async handleMigrationSingle({ id }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: true }); + const config = await this.getConfig({ withCache: true }); const storageTemplateEnabled = config.storageTemplate.enabled; if (!storageTemplateEnabled) { return JobStatus.SKIPPED; @@ -141,7 +111,7 @@ export class StorageTemplateService { async handleMigration(): Promise { this.logger.log('Starting storage template migration'); - const { storageTemplate } = await this.configCore.getConfig({ withCache: true }); + const { storageTemplate } = await this.getConfig({ withCache: true }); const { enabled } = storageTemplate; if (!enabled) { this.logger.log('Storage template migration disabled, skipping'); @@ -283,14 +253,6 @@ export class StorageTemplateService { } } - private onConfig(config: SystemConfig) { - const template = config.storageTemplate.template; - if (!this._template || template !== this.template.raw) { - this.logger.debug(`Compiling new storage template: ${template}`); - this._template = this.compile(template); - } - } - private compile(template: string) { return { raw: template, diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index b0f38554cb032..a4903a3987b10 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,29 +1,23 @@ import { SystemMetadataKey } from 'src/enum'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { StorageService } from 'src/services/storage.service'; -import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { ImmichStartupError, StorageService } from 'src/services/storage.service'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(StorageService.name, () => { let sut: StorageService; - let databaseMock: Mocked; - let storageMock: Mocked; + + let configMock: Mocked; let loggerMock: Mocked; + let storageMock: Mocked; let systemMock: Mocked; beforeEach(() => { - databaseMock = newDatabaseRepositoryMock(); - storageMock = newStorageRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - - sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock); + ({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService)); }); it('should work', () => { @@ -41,23 +35,61 @@ describe(StorageService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { systemMock.get.mockResolvedValue({ mountFiles: true }); storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); - await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to read'); - expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled(); expect(systemMock.set).not.toHaveBeenCalled(); }); it('should throw an error if .immich is present but read-only', async () => { systemMock.get.mockResolvedValue({ mountFiles: true }); - storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to write'); + + expect(systemMock.set).not.toHaveBeenCalled(); + }); + + it('should skip mount file creation if file already exists', async () => { + const error = new Error('Error creating file') as any; + error.code = 'EEXIST'; + systemMock.get.mockResolvedValue({ mountFiles: false }); + storageMock.createFile.mockRejectedValue(error); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(loggerMock.warn).toHaveBeenCalledWith('Found existing mount file, skipping creation'); + }); + + it('should throw an error if mount file could not be created', async () => { + systemMock.get.mockResolvedValue({ mountFiles: false }); + storageMock.createFile.mockRejectedValue(new Error('Error creating file')); + + await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError); + expect(systemMock.set).not.toHaveBeenCalled(); + }); + + it('should startup if checks are disabled', async () => { + systemMock.get.mockResolvedValue({ mountFiles: true }); + configMock.getEnv.mockReturnValue( + mockEnvData({ + storage: { ignoreMountCheckErrors: true }, + }), + ); + storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); - await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(systemMock.set).not.toHaveBeenCalled(); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index a8f6a76e747e1..e8620b4371dd0 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,51 +1,56 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { join } from 'node:path'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { OnEmit } from 'src/decorators'; -import { SystemMetadataKey } from 'src/enum'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } from 'src/decorators'; +import { StorageFolder, SystemMetadataKey } from 'src/enum'; +import { DatabaseLock } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { ImmichStartupError } from 'src/utils/events'; +import { BaseService } from 'src/services/base.service'; -@Injectable() -export class StorageService { - constructor( - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - @Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository, - ) { - this.logger.setContext(StorageService.name); - } +export class ImmichStartupError extends Error {} +export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; + +const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`; - @OnEmit({ event: 'app.bootstrap' }) +@Injectable() +export class StorageService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { + const envData = this.configRepository.getEnv(); + await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { - const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; + const flags = (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; + const enabled = flags.mountFiles ?? false; - this.logger.log('Verifying system mount folder checks'); + this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`); - // check each folder exists and is writable - for (const folder of Object.values(StorageFolder)) { - if (!flags.mountFiles) { - this.logger.log(`Writing initial mount file for the ${folder} folder`); + try { + // check each folder exists and is writable + for (const folder of Object.values(StorageFolder)) { + if (!enabled) { + this.logger.log(`Writing initial mount file for the ${folder} folder`); + await this.createMountFile(folder); + } + + await this.verifyReadAccess(folder); await this.verifyWriteAccess(folder); } - await this.verifyReadAccess(folder); - await this.verifyWriteAccess(folder); - } + if (!flags.mountFiles) { + flags.mountFiles = true; + await this.systemMetadataRepository.set(SystemMetadataKey.SYSTEM_FLAGS, flags); + this.logger.log('Successfully enabled system mount folders checks'); + } - if (!flags.mountFiles) { - flags.mountFiles = true; - await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags); - this.logger.log('Successfully enabled system mount folders checks'); + this.logger.log('Successfully verified system mount folder checks'); + } catch (error) { + if (envData.storage.ignoreMountCheckErrors) { + this.logger.error(error); + this.logger.warn('Ignoring mount folder errors'); + } else { + throw error; + } } - - this.logger.log('Successfully verified system mount folder checks'); }); } @@ -69,36 +74,45 @@ export class StorageService { } private async verifyReadAccess(folder: StorageFolder) { - const { filePath } = this.getMountFilePaths(folder); + const { internalPath, externalPath } = this.getMountFilePaths(folder); try { - await this.storageRepository.readFile(filePath); + await this.storageRepository.readFile(internalPath); } catch (error) { - this.logger.error(`Failed to read ${filePath}: ${error}`); - this.logger.error( - `The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`, - ); - throw new ImmichStartupError(`Failed to validate folder mount (read from "/${folder}")`); + this.logger.error(`Failed to read ${internalPath}: ${error}`); + throw new ImmichStartupError(`Failed to read "${externalPath} - ${docsMessage}"`); } } - private async verifyWriteAccess(folder: StorageFolder) { - const { folderPath, filePath } = this.getMountFilePaths(folder); + private async createMountFile(folder: StorageFolder) { + const { folderPath, internalPath, externalPath } = this.getMountFilePaths(folder); try { this.storageRepository.mkdirSync(folderPath); - await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`)); + await this.storageRepository.createFile(internalPath, Buffer.from(`${Date.now()}`)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + this.logger.warn('Found existing mount file, skipping creation'); + return; + } + this.logger.error(`Failed to create ${internalPath}: ${error}`); + throw new ImmichStartupError(`Failed to create "${externalPath} - ${docsMessage}"`); + } + } + + private async verifyWriteAccess(folder: StorageFolder) { + const { internalPath, externalPath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.overwriteFile(internalPath, Buffer.from(`${Date.now()}`)); } catch (error) { - this.logger.error(`Failed to write ${filePath}: ${error}`); - this.logger.error( - `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, - ); - throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + this.logger.error(`Failed to write ${internalPath}: ${error}`); + throw new ImmichStartupError(`Failed to write "${externalPath} - ${docsMessage}"`); } } private getMountFilePaths(folder: StorageFolder) { const folderPath = StorageCore.getBaseFolder(folder); - const filePath = join(folderPath, '.immich'); + const internalPath = join(folderPath, '.immich'); + const externalPath = `/${folder}/.immich`; - return { folderPath, filePath }; + return { folderPath, internalPath, externalPath }; } } diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index a0ded6dba3626..8dc270d020555 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -1,6 +1,5 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetEntity } from 'src/entities/asset.entity'; -import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; @@ -8,10 +7,7 @@ import { SyncService } from 'src/services/sync.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; -import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const untilDate = new Date(2024); @@ -19,17 +15,13 @@ const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: tr describe(SyncService.name, () => { let sut: SyncService; - let accessMock: Mocked; + let assetMock: Mocked; - let partnerMock: Mocked; let auditMock: Mocked; + let partnerMock: Mocked; beforeEach(() => { - partnerMock = newPartnerRepositoryMock(); - assetMock = newAssetRepositoryMock(); - accessMock = newAccessRepositoryMock(); - auditMock = newAuditRepositoryMock(); - sut = new SyncService(accessMock, assetMock, partnerMock, auditMock); + ({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService)); }); it('should exist', () => { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 7da3fbd9be58d..e09c06c778a08 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,32 +1,21 @@ -import { Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; import { DatabaseAction, EntityType, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IAuditRepository } from 'src/interfaces/audit.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { setIsEqual } from 'src/utils/set'; const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] }; -export class SyncService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IAuditRepository) private auditRepository: IAuditRepository, - ) {} - +export class SyncService extends BaseService { async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; - await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const assets = await this.assetRepository.getAllForUserFullSync({ ownerId: userId, updatedUntil: dto.updatedUntil, @@ -50,7 +39,7 @@ export class SyncService { return FULL_SYNC; } - await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); const limit = 10_000; const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 409cd6a52f360..52a5b1dcd8b43 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -1,27 +1,25 @@ import { BadRequestException } from '@nestjs/common'; +import { defaults, SystemConfig } from 'src/config'; import { AudioCodec, - CQMode, Colorspace, + CQMode, ImageFormat, LogLevel, - SystemConfig, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, - defaults, -} from 'src/config'; -import { SystemMetadataKey } from 'src/enum'; -import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; +} from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemConfigService } from 'src/services/system-config.service'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { DeepPartial } from 'typeorm'; import { Mocked } from 'vitest'; @@ -100,8 +98,8 @@ const updatedConfig = Object.freeze({ }, map: { enabled: true, - lightStyle: '', - darkStyle: '', + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', }, reverseGeocoding: { enabled: true, @@ -136,11 +134,16 @@ const updatedConfig = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + size: 250, + format: ImageFormat.WEBP, + quality: 80, + }, + preview: { + size: 1440, + format: ImageFormat.JPEG, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, @@ -184,16 +187,14 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; - let systemMock: Mocked; + + let configMock: Mocked; let eventMock: Mocked; let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - delete process.env.IMMICH_CONFIG_FILE; - systemMock = newSystemMetadataRepositoryMock(); - eventMock = newEventRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new SystemConfigService(systemMock, eventMock, loggerMock); + ({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService)); }); it('should work', () => { @@ -213,7 +214,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', async () => { systemMock.get.mockResolvedValue({}); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { @@ -224,25 +225,24 @@ describe(SystemConfigService.name, () => { user: { deleteDelay: 15 }, }); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; - + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should log errors with the config file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); expect(loggerMock.error).toHaveBeenCalledTimes(2); @@ -253,7 +253,7 @@ describe(SystemConfigService.name, () => { }); it('should load the config from a yaml file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` ffmpeg: crf: 30 @@ -266,37 +266,54 @@ describe(SystemConfigService.name, () => { `; systemMock.readFile.mockResolvedValue(partialConfig); - await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); it('should accept an empty configuration file', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.getConfig()).resolves.toEqual(defaults); + await expect(sut.getSystemConfig()).resolves.toEqual(defaults); expect(systemMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); it('should allow underscores in the machine learning url', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); const partialConfig = { machineLearning: { url: 'immich_machine_learning' } }; systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getConfig(); + const config = await sut.getSystemConfig(); expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); + const externalDomainTests = [ + { should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' }, + { should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' }, + { should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' }, + ]; + + for (const { should, externalDomain, result } of externalDomainTests) { + it(`should normalize an external domain ${should}`, async () => { + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + const partialConfig = { server: { externalDomain } }; + systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + const config = await sut.getSystemConfig(); + expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); + }); + } + it('should warn for unknown options in yaml', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` unknownOption: true `; systemMock.readFile.mockResolvedValue(partialConfig); - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); }); @@ -311,14 +328,14 @@ describe(SystemConfigService.name, () => { for (const test of tests) { it(`should ${test.should}`, async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.warn) { - await sut.getConfig(); + await sut.getSystemConfig(); expect(loggerMock.warn).toHaveBeenCalled(); } else { - await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); } }); } @@ -360,20 +377,19 @@ describe(SystemConfigService.name, () => { }); describe('updateConfig', () => { - it('should update the config and emit client and server events', async () => { + it('should update the config and emit an event', async () => { systemMock.get.mockResolvedValue(partialConfig); - - await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig); - - expect(eventMock.clientBroadcast).toHaveBeenCalled(); - expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); + expect(eventMock.emit).toHaveBeenCalledWith( + 'config.update', + expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), + ); }); it('should throw an error if a config file is in use', async () => { - process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); systemMock.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 5ec9ab7a5db05..96a1f0897bb36 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,7 +1,7 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; -import { LogLevel, SystemConfig, defaults } from 'src/config'; +import { defaults } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -12,36 +12,23 @@ import { supportedWeekTokens, supportedYearTokens, } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; -import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; +import { BaseService } from 'src/services/base.service'; +import { clearConfigCache } from 'src/utils/config'; import { toPlainObject } from 'src/utils/object'; @Injectable() -export class SystemConfigService { - private core: SystemConfigCore; - - constructor( - @Inject(ISystemMetadataRepository) repository: ISystemMetadataRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(SystemConfigService.name); - this.core = SystemConfigCore.create(repository, this.logger); - this.core.config$.subscribe((config) => this.setLogLevel(config)); - } - - @OnEmit({ event: 'app.bootstrap', priority: -100 }) +export class SystemConfigService extends BaseService { + @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { - const config = await this.core.getConfig({ withCache: false }); - this.core.config$.next(config); + const config = await this.getConfig({ withCache: false }); + await this.eventRepository.emit('config.update', { newConfig: config }); } - async getConfig(): Promise { - const config = await this.core.getConfig({ withCache: false }); + async getSystemConfig(): Promise { + const config = await this.getConfig({ withCache: false }); return mapConfig(config); } @@ -49,19 +36,32 @@ export class SystemConfigService { return mapConfig(defaults); } - @OnEmit({ event: 'config.validate' }) + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) { + const { logLevel: envLevel } = this.configRepository.getEnv(); + const configLevel = logging.enabled ? logging.level : false; + const level = envLevel ?? configLevel; + this.logger.setLogLevel(level); + this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); + // TODO only do this if the event is a socket.io event + clearConfigCache(); + } + + @OnEvent({ name: 'config.validate' }) onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) { - if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) { + const { logLevel } = this.configRepository.getEnv(); + if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && logLevel) { throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.'); } } - async updateConfig(dto: SystemConfigDto): Promise { - if (this.core.isUsingConfigFile()) { + async updateSystemConfig(dto: SystemConfigDto): Promise { + const { configFile } = this.configRepository.getEnv(); + if (configFile) { throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); } - const oldConfig = await this.core.getConfig({ withCache: false }); + const oldConfig = await this.getConfig({ withCache: false }); try { await this.eventRepository.emit('config.validate', { newConfig: toPlainObject(dto), oldConfig }); @@ -70,11 +70,8 @@ export class SystemConfigService { throw new BadRequestException(error instanceof Error ? error.message : error); } - const newConfig = await this.core.updateConfig(dto); + const newConfig = await this.updateConfig(dto); - // TODO probably move web socket emits to a separate service - this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {}); - this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null); await this.eventRepository.emit('config.update', { newConfig, oldConfig }); return mapConfig(newConfig); @@ -96,24 +93,7 @@ export class SystemConfigService { } async getCustomCss(): Promise { - const { theme } = await this.core.getConfig({ withCache: false }); + const { theme } = await this.getConfig({ withCache: false }); return theme.customCss; } - - @OnServerEvent(ServerEvent.CONFIG_UPDATE) - async onConfigUpdateEvent() { - await this.core.refreshConfig(); - } - - private setLogLevel({ logging }: SystemConfig) { - const envLevel = this.getEnvLogLevel(); - const configLevel = logging.enabled ? logging.level : false; - const level = envLevel ?? configLevel; - this.logger.setLogLevel(level); - this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); - } - - private getEnvLogLevel() { - return process.env.IMMICH_LOG_LEVEL as LogLevel; - } } diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 5799ee859d8c6..3dc2f0a6bb7f0 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -1,31 +1,60 @@ import { SystemMetadataKey } from 'src/enum'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { SystemMetadataService } from 'src/services/system-metadata.service'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(SystemMetadataService.name, () => { let sut: SystemMetadataService; - let metadataMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - metadataMock = newSystemMetadataRepositoryMock(); - sut = new SystemMetadataService(metadataMock); + ({ sut, systemMock } = newTestService(SystemMetadataService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('getAdminOnboarding', () => { + it('should get isOnboarded state', async () => { + systemMock.get.mockResolvedValue({ isOnboarded: true }); + await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: true }); + expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + }); + + it('should default isOnboarded to false', async () => { + await expect(sut.getAdminOnboarding()).resolves.toEqual({ isOnboarded: false }); + expect(systemMock.get).toHaveBeenCalledWith('admin-onboarding'); + }); + }); + describe('updateAdminOnboarding', () => { it('should update isOnboarded to true', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); - expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); }); it('should update isOnboarded to false', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); - expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + }); + }); + + describe('getReverseGeocodingState', () => { + it('should get reverse geocoding state', async () => { + systemMock.get.mockResolvedValue({ lastUpdate: '2024-01-01', lastImportFileName: 'foo.bar' }); + await expect(sut.getReverseGeocodingState()).resolves.toEqual({ + lastUpdate: '2024-01-01', + lastImportFileName: 'foo.bar', + }); + }); + + it('should default reverse geocoding state to null', async () => { + await expect(sut.getReverseGeocodingState()).resolves.toEqual({ + lastUpdate: null, + lastImportFileName: null, + }); }); }); }); diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts index c2c9a4fdfc8c2..93449c7a7b5d7 100644 --- a/server/src/services/system-metadata.service.ts +++ b/server/src/services/system-metadata.service.ts @@ -1,29 +1,27 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AdminOnboardingResponseDto, AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto, } from 'src/dtos/system-metadata.dto'; import { SystemMetadataKey } from 'src/enum'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BaseService } from 'src/services/base.service'; @Injectable() -export class SystemMetadataService { - constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {} - +export class SystemMetadataService extends BaseService { async getAdminOnboarding(): Promise { - const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING); + const value = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); return { isOnboarded: false, ...value }; } async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise { - await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: dto.isOnboarded, }); } async getReverseGeocodingState(): Promise { - const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + const value = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); return { lastUpdate: null, lastImportFileName: null, ...value }; } } diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index de270777b06c5..54cef40d042c1 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -1,26 +1,22 @@ import { BadRequestException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; -import { IEventRepository } from 'src/interfaces/event.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; import { ITagRepository } from 'src/interfaces/tag.interface'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(TagService.name, () => { let sut: TagService; + let accessMock: IAccessRepositoryMock; - let eventMock: Mocked; let tagMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - eventMock = newEventRepositoryMock(); - tagMock = newTagRepositoryMock(); - sut = new TagService(accessMock, eventMock, tagMock); + ({ sut, accessMock, tagMock } = newTestService(TagService)); accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1'])); }); @@ -140,6 +136,23 @@ describe(TagService.name, () => { parent: expect.objectContaining({ id: 'tag-parent' }), }); }); + + it('should upsert a tag and ignore leading and trailing slashes', async () => { + tagMock.getByValue.mockResolvedValueOnce(null); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent); + tagMock.upsertValue.mockResolvedValueOnce(tagStub.child); + await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined(); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, { + value: 'Parent', + userId: 'admin_id', + parent: undefined, + }); + expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, { + value: 'Parent/Child', + userId: 'admin_id', + parent: expect.objectContaining({ id: 'tag-parent' }), + }); + }); }); describe('remove', () => { @@ -249,4 +262,11 @@ describe(TagService.name, () => { expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); }); }); + + describe('handleTagCleanup', () => { + it('should delete empty tags', async () => { + await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS); + expect(tagMock.deleteEmptyTags).toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 97b0ef1be6843..2824a9832ddea 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -12,28 +12,22 @@ import { } from 'src/dtos/tag.dto'; import { TagEntity } from 'src/entities/tag.entity'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; +import { AssetTagItem } from 'src/interfaces/tag.interface'; +import { BaseService } from 'src/services/base.service'; import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; import { upsertTags } from 'src/utils/tag'; @Injectable() -export class TagService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ITagRepository) private repository: ITagRepository, - ) {} - +export class TagService extends BaseService { async getAll(auth: AuthDto) { - const tags = await this.repository.getAll(auth.user.id); + const tags = await this.tagRepository.getAll(auth.user.id); return tags.map((tag) => mapTag(tag)); } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [id] }); const tag = await this.findOrFail(id); return mapTag(tag); } @@ -41,8 +35,8 @@ export class TagService { async create(auth: AuthDto, dto: TagCreateDto) { let parent: TagEntity | undefined; if (dto.parentId) { - await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); - parent = (await this.repository.get(dto.parentId)) || undefined; + await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); + parent = (await this.tagRepository.get(dto.parentId)) || undefined; if (!parent) { throw new BadRequestException('Tag not found'); } @@ -50,41 +44,41 @@ export class TagService { const userId = auth.user.id; const value = parent ? `${parent.value}/${dto.name}` : dto.name; - const duplicate = await this.repository.getByValue(userId, value); + const duplicate = await this.tagRepository.getByValue(userId, value); if (duplicate) { throw new BadRequestException(`A tag with that name already exists`); } - const tag = await this.repository.create({ userId, value, parent }); + const tag = await this.tagRepository.create({ userId, value, parent }); return mapTag(tag); } async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_UPDATE, ids: [id] }); const { color } = dto; - const tag = await this.repository.update({ id, color }); + const tag = await this.tagRepository.update({ id, color }); return mapTag(tag); } async upsert(auth: AuthDto, dto: TagUpsertDto) { - const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags }); + const tags = await upsertTags(this.tagRepository, { userId: auth.user.id, tags: dto.tags }); return tags.map((tag) => mapTag(tag)); } async remove(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_DELETE, ids: [id] }); // TODO sync tag changes for affected assets - await this.repository.delete(id); + await this.tagRepository.delete(id); } async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise { const [tagIds, assetIds] = await Promise.all([ - checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), - checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), + checkAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), + checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), ]); const items: AssetTagItem[] = []; @@ -94,7 +88,7 @@ export class TagService { } } - const results = await this.repository.upsertAssetIds(items); + const results = await this.tagRepository.upsertAssetIds(items); for (const assetId of new Set(results.map((item) => item.assetId))) { await this.eventRepository.emit('asset.tag', { assetId }); } @@ -103,11 +97,11 @@ export class TagService { } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] }); const results = await addAssets( auth, - { access: this.access, bulk: this.repository }, + { access: this.accessRepository, bulk: this.tagRepository }, { parentId: id, assetIds: dto.ids }, ); @@ -121,11 +115,11 @@ export class TagService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] }); const results = await removeAssets( auth, - { access: this.access, bulk: this.repository }, + { access: this.accessRepository, bulk: this.tagRepository }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE }, ); @@ -138,8 +132,13 @@ export class TagService { return results; } + async handleTagCleanup() { + await this.tagRepository.deleteEmptyTags(); + return JobStatus.SUCCESS; + } + private async findOrFail(id: string) { - const tag = await this.repository.get(id); + const tag = await this.tagRepository.get(id); if (!tag) { throw new BadRequestException('Tag not found'); } diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 981fc11c3f5ab..db6890c27bd0d 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,25 +1,20 @@ import { BadRequestException } from '@nestjs/common'; import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(TimelineService.name, () => { let sut: TimelineService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let partnerMock: Mocked; - beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - sut = new TimelineService(accessMock, assetMock, partnerMock); + beforeEach(() => { + ({ sut, accessMock, assetMock } = newTestService(TimelineService)); }); describe('getTimeBuckets', () => { @@ -74,6 +69,70 @@ describe(TimelineService.name, () => { }); }); + it('should include partner shared assets', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: false, + userId: authStub.admin.user.id, + withPartners: true, + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: false, + withPartners: true, + userIds: [authStub.admin.user.id], + }); + }); + + it('should check permissions to read tag', async () => { + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123'])); + + await expect( + sut.getTimeBucket(authStub.admin, { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + userId: authStub.admin.user.id, + tagId: 'tag-123', + }), + ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + tagId: 'tag-123', + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }); + }); + + it('should strip metadata if showExif is disabled', async () => { + accessMock.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id'])); + assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); + + const buckets = await sut.getTimeBucket( + { ...authStub.admin, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } }, + { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + albumId: 'album-id', + }, + ); + expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]); + expect(buckets[0]).not.toHaveProperty('exif'); + expect(assetMock.getTimeBucket).toHaveBeenCalledWith('bucket', { + size: TimeBucketSize.DAY, + timeBucket: 'bucket', + isArchived: true, + albumId: 'album-id', + }); + }); + it('should return the assets for a library time bucket if user has library.read', async () => { assetMock.getTimeBucket.mockResolvedValue([assetStub.image]); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index bc08505b944eb..48e4daafd1096 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,26 +1,18 @@ -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; -export class TimelineService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private repository: IAssetRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - ) {} - +export class TimelineService extends BaseService { async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - - return this.repository.getTimeBuckets(timeBucketOptions); + return this.assetRepository.getTimeBuckets(timeBucketOptions); } async getTimeBucket( @@ -29,7 +21,7 @@ export class TimelineService { ): Promise { await this.timeBucketChecks(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions); + const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); return !auth.sharedLink || auth.sharedLink?.showExif ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); @@ -56,20 +48,20 @@ export class TimelineService { private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { if (dto.albumId) { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); } else { dto.userId = dto.userId || auth.user.id; } if (dto.userId) { - await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); if (dto.isArchived !== false) { - await requireAccess(this.access, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); } } if (dto.tagId) { - await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); + await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); } if (dto.withPartners) { diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index 73a4f3d57b95f..748faa14abdc2 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,34 +1,25 @@ import { BadRequestException } from '@nestjs/common'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; +import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { ITrashRepository } from 'src/interfaces/trash.interface'; import { TrashService } from 'src/services/trash.service'; -import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(TrashService.name, () => { let sut: TrashService; + let accessMock: IAccessRepositoryMock; - let assetMock: Mocked; let jobMock: Mocked; - let eventMock: Mocked; + let trashMock: Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - - sut = new TrashService(accessMock, assetMock, jobMock, eventMock); + ({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService)); }); describe('restoreAssets', () => { @@ -40,46 +31,70 @@ describe(TrashService.name, () => { ).rejects.toBeInstanceOf(BadRequestException); }); + it('should handle an empty list', async () => { + await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 }); + expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled(); + }); + it('should restore a batch of assets', async () => { accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2'])); await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] }); - expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); + expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); expect(jobMock.queue.mock.calls).toEqual([]); }); }); describe('restore', () => { it('should handle an empty trash', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false }); - await expect(sut.restore(authStub.user1)).resolves.toBeUndefined(); - expect(assetMock.restoreAll).not.toHaveBeenCalled(); - expect(eventMock.clientSend).not.toHaveBeenCalled(); + trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false }); + trashMock.restore.mockResolvedValue(0); + await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 }); + expect(trashMock.restore).toHaveBeenCalledWith('user-id'); }); - it('should restore and notify', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); - await expect(sut.restore(authStub.user1)).resolves.toBeUndefined(); - expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]); - expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [ - assetStub.image.id, - ]); + it('should restore', async () => { + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); + trashMock.restore.mockResolvedValue(1); + await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); + expect(trashMock.restore).toHaveBeenCalledWith('user-id'); }); }); describe('empty', () => { it('should handle an empty trash', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false }); - await expect(sut.empty(authStub.user1)).resolves.toBeUndefined(); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false }); + trashMock.empty.mockResolvedValue(0); + await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 }); + expect(jobMock.queue).not.toHaveBeenCalled(); }); it('should empty the trash', async () => { - assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); - await expect(sut.empty(authStub.user1)).resolves.toBeUndefined(); + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); + trashMock.empty.mockResolvedValue(1); + await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); + expect(trashMock.empty).toHaveBeenCalledWith('user-id'); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + }); + }); + + describe('onAssetsDelete', () => { + it('should queue the empty trash job', async () => { + await expect(sut.onAssetsDelete()).resolves.toBeUndefined(); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + }); + }); + + describe('handleQueueEmptyTrash', () => { + it('should queue asset delete jobs', async () => { + trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false }); + await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS); expect(jobMock.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } }, + { + name: JobName.ASSET_DELETION, + data: { id: 'asset-1', deleteOnDisk: true }, + }, ]); }); }); diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index ac141521ddc18..add6e29f6bb14 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,69 +1,72 @@ -import { Inject } from '@nestjs/common'; -import { DateTime } from 'luxon'; +import { OnEvent } from 'src/decorators'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { TrashResponseDto } from 'src/dtos/trash.dto'; import { Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface'; +import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; import { requireAccess } from 'src/utils/access'; import { usePagination } from 'src/utils/pagination'; -export class TrashService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - ) {} - - async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { +export class TrashService extends BaseService { + async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { const { ids } = dto; - await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids }); - await this.restoreAndSend(auth, ids); + if (ids.length === 0) { + return { count: 0 }; + } + + await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids }); + await this.trashRepository.restoreAll(ids); + await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id }); + + this.logger.log(`Restored ${ids.length} assets from trash`); + + return { count: ids.length }; } - async restore(auth: AuthDto): Promise { - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { - trashedBefore: DateTime.now().toJSDate(), - }), - ); + async restore(auth: AuthDto): Promise { + const count = await this.trashRepository.restore(auth.user.id); + if (count > 0) { + this.logger.log(`Restored ${count} assets from trash`); + } + return { count }; + } - for await (const assets of assetPagination) { - const ids = assets.map((a) => a.id); - await this.restoreAndSend(auth, ids); + async empty(auth: AuthDto): Promise { + const count = await this.trashRepository.empty(auth.user.id); + if (count > 0) { + await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); } + return { count }; + } + + @OnEvent({ name: 'assets.delete' }) + async onAssetsDelete() { + await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); } - async empty(auth: AuthDto): Promise { + async handleQueueEmptyTrash() { + let count = 0; const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { - trashedBefore: DateTime.now().toJSDate(), - withArchived: true, - }), + this.trashRepository.getDeletedIds(pagination), ); - for await (const assets of assetPagination) { + for await (const assetIds of assetPagination) { + this.logger.debug(`Queueing ${assetIds.length} assets for deletion from the trash`); + count += assetIds.length; await this.jobRepository.queueAll( - assets.map((asset) => ({ + assetIds.map((assetId) => ({ name: JobName.ASSET_DELETION, data: { - id: asset.id, + id: assetId, deleteOnDisk: true, }, })), ); } - } - private async restoreAndSend(auth: AuthDto, ids: string[]) { - if (ids.length === 0) { - return; - } + this.logger.log(`Queued ${count} assets for deletion from the trash`); - await this.assetRepository.restoreAll(ids); - this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, auth.user.id, ids); + return JobStatus.SUCCESS; } } diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 8e80aa4dc109a..70999332dc26a 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -1,41 +1,22 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { mapUserAdmin } from 'src/dtos/user.dto'; import { UserStatus } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserAdminService } from 'src/services/user-admin.service'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked, describe } from 'vitest'; describe(UserAdminService.name, () => { let sut: UserAdminService; - let albumMock: Mocked; - let cryptoMock: Mocked; - let eventMock: Mocked; + let jobMock: Mocked; - let loggerMock: Mocked; let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new UserAdminService(albumMock, cryptoMock, eventMock, jobMock, userMock, loggerMock); + ({ sut, jobMock, userMock } = newTestService(UserAdminService)); userMock.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 6a5b6ea06e520..94608a24ac035 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -1,6 +1,5 @@ -import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; -import { UserCore } from 'src/cores/user.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { @@ -12,30 +11,14 @@ import { mapUserAdmin, } from 'src/dtos/user.dto'; import { UserMetadataKey, UserStatus } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; +import { JobName } from 'src/interfaces/job.interface'; +import { UserFindOptions } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; +import { createUser } from 'src/utils/user'; @Injectable() -export class UserAdminService { - private userCore: UserCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.userCore = UserCore.create(cryptoRepository, userRepository); - this.logger.setContext(UserAdminService.name); - } - +export class UserAdminService extends BaseService { async search(auth: AuthDto, dto: UserAdminSearchDto): Promise { const users = await this.userRepository.getList({ withDeleted: dto.withDeleted }); return users.map((user) => mapUserAdmin(user)); @@ -43,7 +26,7 @@ export class UserAdminService { async create(dto: UserAdminCreateDto): Promise { const { notify, ...rest } = dto; - const user = await this.userCore.createUser(rest); + const user = await createUser({ userRepo: this.userRepository, cryptoRepo: this.cryptoRepository }, rest); await this.eventRepository.emit('user.signup', { notify: !!notify, diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 0ac0ea6dbc7cf..767d8d895453a 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,25 +1,17 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { UserEntity } from 'src/entities/user.entity'; -import { UserMetadataKey } from 'src/enum'; +import { CacheControl, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserService } from 'src/services/user.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; -import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; -import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const makeDeletedAt = (daysAgo: number) => { @@ -30,25 +22,15 @@ const makeDeletedAt = (daysAgo: number) => { describe(UserService.name, () => { let sut: UserService; - let userMock: Mocked; - let cryptoRepositoryMock: Mocked; let albumMock: Mocked; let jobMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; + let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - cryptoRepositoryMock = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new UserService(albumMock, cryptoRepositoryMock, jobMock, storageMock, systemMock, userMock, loggerMock); + ({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService)); userMock.get.mockImplementation((userId) => Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 92404a6958f3c..f67d04cbd3405 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,44 +1,23 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { getClientLicensePublicKey, getServerLicensePublicKey } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; -import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; +import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IStorageRepository } from 'src/interfaces/storage.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; -import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; +import { IEntityJob, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { UserFindOptions } from 'src/interfaces/user.interface'; +import { BaseService } from 'src/services/base.service'; +import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() -export class UserService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(UserService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - +export class UserService extends BaseService { async search(): Promise { const users = await this.userRepository.getList({ withDeleted: false }); return users.map((user) => mapUser(user)); @@ -93,13 +72,23 @@ export class UserService { return mapUser(user); } - async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise { + async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise { const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false }); - const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path }); + + const user = await this.userRepository.update(auth.user.id, { + profileImagePath: file.path, + profileChangedAt: new Date(), + }); + if (oldpath !== '') { await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } }); } - return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath); + + return { + userId: user.id, + profileImagePath: user.profileImagePath, + profileChangedAt: user.profileChangedAt, + }; } async deleteProfileImage(auth: AuthDto): Promise { @@ -107,7 +96,7 @@ export class UserService { if (user.profileImagePath === '') { throw new BadRequestException("Can't delete a missing profile Image"); } - await this.userRepository.update(auth.user.id, { profileImagePath: '' }); + await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() }); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } }); } @@ -143,16 +132,18 @@ export class UserService { throw new BadRequestException('Invalid license key'); } + const { licensePublicKey } = this.configRepository.getEnv(); + const clientLicenseValid = this.cryptoRepository.verifySha256( license.licenseKey, license.activationKey, - getClientLicensePublicKey(), + licensePublicKey.client, ); const serverLicenseValid = this.cryptoRepository.verifySha256( license.licenseKey, license.activationKey, - getServerLicensePublicKey(), + licensePublicKey.server, ); if (!clientLicenseValid && !serverLicenseValid) { @@ -179,7 +170,7 @@ export class UserService { async handleUserDeleteCheck(): Promise { const users = await this.userRepository.getDeletedUsers(); - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); await this.jobRepository.queueAll( users.flatMap((user) => this.isReadyForDeletion(user, config.user.deleteDelay) @@ -191,7 +182,7 @@ export class UserService { } async handleUserDelete({ id, force }: IEntityJob): Promise { - const config = await this.configCore.getConfig({ withCache: false }); + const config = await this.getConfig({ withCache: false }); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { return JobStatus.FAILED; diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 02dfe7588fa50..46f8f620c474a 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,17 +1,17 @@ import { DateTime } from 'luxon'; +import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; -import { SystemMetadataKey } from 'src/enum'; +import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; import { VersionService } from 'src/services/version.service'; -import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; -import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const mockRelease = (version: string) => ({ @@ -26,26 +26,41 @@ const mockRelease = (version: string) => ({ describe(VersionService.name, () => { let sut: VersionService; + + let configMock: Mocked; let eventMock: Mocked; let jobMock: Mocked; - let serverMock: Mocked; - let systemMock: Mocked; let loggerMock: Mocked; + let serverInfoMock: Mocked; + let systemMock: Mocked; + let versionHistoryMock: Mocked; beforeEach(() => { - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - serverMock = newServerInfoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock); + ({ sut, configMock, eventMock, jobMock, loggerMock, serverInfoMock, systemMock, versionHistoryMock } = + newTestService(VersionService)); }); it('should work', () => { expect(sut).toBeDefined(); }); + describe('onBootstrap', () => { + it('should record a new version', async () => { + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + expect(versionHistoryMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); + }); + + it('should skip a duplicate version', async () => { + versionHistoryMock.getLatest.mockResolvedValue({ + id: 'version-1', + createdAt: new Date(), + version: serverVersion.toString(), + }); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + expect(versionHistoryMock.create).not.toHaveBeenCalled(); + }); + }); + describe('getVersion', () => { it('should respond the server version', () => { expect(sut.getVersion()).toEqual({ @@ -56,6 +71,14 @@ describe(VersionService.name, () => { }); }); + describe('getVersionHistory', () => { + it('should respond the server version history', async () => { + const upgrade = { id: 'upgrade-1', createdAt: new Date(), version: '1.0.0' }; + versionHistoryMock.getAll.mockResolvedValue([upgrade]); + await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]); + }); + }); + describe('handQueueVersionCheck', () => { it('should queue a version check job', async () => { await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); @@ -65,11 +88,11 @@ describe(VersionService.name, () => { describe('handVersionCheck', () => { beforeEach(() => { - process.env.IMMICH_ENV = 'production'; + configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); }); it('should not run in dev mode', async () => { - process.env.IMMICH_ENV = 'development'; + configMock.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); @@ -81,8 +104,13 @@ describe(VersionService.name, () => { await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); }); + it('should not run if version check is disabled', async () => { + systemMock.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); + }); + it('should run if it has been > 60 minutes', async () => { - serverMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); + serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease('v100.0.0')); systemMock.get.mockResolvedValue({ checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(), releaseVersion: '1.0.0', @@ -94,7 +122,7 @@ describe(VersionService.name, () => { }); it('should not notify if the version is equal', async () => { - serverMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); + serverInfoMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { checkedAt: expect.any(String), @@ -104,11 +132,26 @@ describe(VersionService.name, () => { }); it('should handle a github error', async () => { - serverMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); + serverInfoMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED); expect(systemMock.set).not.toHaveBeenCalled(); expect(eventMock.clientBroadcast).not.toHaveBeenCalled(); expect(loggerMock.warn).toHaveBeenCalled(); }); }); + + describe('onWebsocketConnectionEvent', () => { + it('should send on_server_version client event', async () => { + await sut.onWebsocketConnection({ userId: '42' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(eventMock.clientSend).toHaveBeenCalledTimes(1); + }); + + it('should also send a new release notification', async () => { + systemMock.get.mockResolvedValue({ checkedAt: '2024-01-01', releaseVersion: 'v1.42.0' }); + await sut.onWebsocketConnection({ userId: '42' }); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer)); + expect(eventMock.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object)); + }); + }); }); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 468e8c9bdd52c..231ced1a950af 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -1,17 +1,15 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; -import { isDev, serverVersion } from 'src/constants'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnEmit, OnServerEvent } from 'src/decorators'; +import { serverVersion } from 'src/constants'; +import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; -import { SystemMetadataKey } from 'src/enum'; -import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { return { @@ -23,29 +21,29 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re }; @Injectable() -export class VersionService { - private configCore: SystemConfigCore; - - constructor( - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IServerInfoRepository) private repository: IServerInfoRepository, - @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(VersionService.name); - this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); - } - - @OnEmit({ event: 'app.bootstrap' }) +export class VersionService extends BaseService { + @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); + + await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => { + const latest = await this.versionRepository.getLatest(); + const current = serverVersion.toString(); + if (!latest || latest.version !== current) { + this.logger.log(`Version has changed, adding ${current} to history`); + await this.versionRepository.create({ version: current }); + } + }); } getVersion() { return ServerVersionResponseDto.fromSemVer(serverVersion); } + getVersionHistory() { + return this.versionRepository.getAll(); + } + async handleQueueVersionCheck() { await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); } @@ -54,11 +52,12 @@ export class VersionService { try { this.logger.debug('Running version check'); - if (isDev()) { + const { environment } = this.configRepository.getEnv(); + if (environment === ImmichEnvironment.DEVELOPMENT) { return JobStatus.SKIPPED; } - const { newVersionCheck } = await this.configCore.getConfig({ withCache: true }); + const { newVersionCheck } = await this.getConfig({ withCache: true }); if (!newVersionCheck.enabled) { return JobStatus.SKIPPED; } @@ -73,14 +72,15 @@ export class VersionService { } } - const { tag_name: releaseVersion, published_at: publishedAt } = await this.repository.getGitHubRelease(); + const { tag_name: releaseVersion, published_at: publishedAt } = + await this.serverInfoRepository.getGitHubRelease(); const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion }; await this.systemMetadataRepository.set(SystemMetadataKey.VERSION_CHECK_STATE, metadata); if (semver.gt(releaseVersion, serverVersion)) { this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); - this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata)); + this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata)); } } catch (error: Error | any) { this.logger.warn(`Unable to run version check: ${error}`, error?.stack); @@ -90,12 +90,12 @@ export class VersionService { return JobStatus.SUCCESS; } - @OnServerEvent(ServerEvent.WEBSOCKET_CONNECT) - async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) { - this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion); + @OnEvent({ name: 'websocket.connect' }) + async onWebsocketConnection({ userId }: ArgOf<'websocket.connect'>) { + this.eventRepository.clientSend('on_server_version', userId, serverVersion); const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); if (metadata) { - this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata)); + this.eventRepository.clientSend('on_new_release', userId, asNotification(metadata)); } } } diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 3f9aa9f2f50a3..e9373ce66fb7f 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,21 +1,18 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; - +import { IViewRepository } from 'src/interfaces/view.interface'; import { ViewService } from 'src/services/view.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; describe(ViewService.name, () => { let sut: ViewService; - let assetMock: Mocked; + let viewMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - - sut = new ViewService(assetMock); + ({ sut, viewMock } = newTestService(ViewService)); }); it('should work', () => { @@ -25,12 +22,12 @@ describe(ViewService.name, () => { describe('getUniqueOriginalPaths', () => { it('should return unique original paths', async () => { const mockPaths = ['path1', 'path2', 'path3']; - assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); + viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths); const result = await sut.getUniqueOriginalPaths(authStub.admin); expect(result).toEqual(mockPaths); - expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); + expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id); }); }); @@ -45,11 +42,11 @@ describe(ViewService.name, () => { const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin })); - assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); + viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets); const result = await sut.getAssetsByOriginalPath(authStub.admin, path); expect(result).toEqual(mockAssetReponseDto); - await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); }); }); }); diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts index 1bf9a3408c63a..cb805368705df 100644 --- a/server/src/services/view.service.ts +++ b/server/src/services/view.service.ts @@ -1,18 +1,14 @@ -import { Inject } from '@nestjs/common'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; - -export class ViewService { - constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} +import { BaseService } from 'src/services/base.service'; +export class ViewService extends BaseService { getUniqueOriginalPaths(auth: AuthDto): Promise { - return this.assetRepository.getUniqueOriginalPaths(auth.user.id); + return this.viewRepository.getUniqueOriginalPaths(auth.user.id); } async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise { - const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path); - + const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path); return assets.map((asset) => mapAsset(asset, { auth })); } } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 26d5f9292ebeb..44c291e139766 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -1,8 +1,12 @@ +import { BadRequestException } from '@nestjs/common'; +import { StorageCore } from 'src/cores/storage.core'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; -import { AssetFileType, Permission } from 'src/enum'; +import { AssetFileType, AssetType, Permission } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { checkAccess } from 'src/utils/access'; @@ -130,3 +134,50 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P return [...partnerIds]; }; + +export type AssetHookRepositories = { asset: IAssetRepository; event: IEventRepository }; + +export const onBeforeLink = async ( + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, + { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, +) => { + const motionAsset = await assetRepository.getById(livePhotoVideoId); + if (!motionAsset) { + throw new BadRequestException('Live photo video not found'); + } + if (motionAsset.type !== AssetType.VIDEO) { + throw new BadRequestException('Live photo video must be a video'); + } + if (motionAsset.ownerId !== userId) { + throw new BadRequestException('Live photo video does not belong to the user'); + } + + if (motionAsset?.isVisible) { + await assetRepository.update({ id: livePhotoVideoId, isVisible: false }); + await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId }); + } +}; + +export const onBeforeUnlink = async ( + { asset: assetRepository }: AssetHookRepositories, + { livePhotoVideoId }: { livePhotoVideoId: string }, +) => { + const motion = await assetRepository.getById(livePhotoVideoId); + if (!motion) { + return null; + } + + if (StorageCore.isAndroidMotionPath(motion.originalPath)) { + throw new BadRequestException('Cannot unlink Android motion photos'); + } + + return motion; +}; + +export const onAfterUnlink = async ( + { asset: assetRepository, event: eventRepository }: AssetHookRepositories, + { userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string }, +) => { + await assetRepository.update({ id: livePhotoVideoId, isVisible: true }); + await eventRepository.emit('asset.show', { assetId: livePhotoVideoId, userId }); +}; diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts new file mode 100644 index 0000000000000..3a6e079e873f8 --- /dev/null +++ b/server/src/utils/config.ts @@ -0,0 +1,128 @@ +import AsyncLock from 'async-lock'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { load as loadYaml } from 'js-yaml'; +import * as _ from 'lodash'; +import { SystemConfig, defaults } from 'src/config'; +import { SystemConfigDto } from 'src/dtos/system-config.dto'; +import { SystemMetadataKey } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { getKeysDeep, unsetDeep } from 'src/utils/misc'; +import { DeepPartial } from 'typeorm'; + +export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise; + +type RepoDeps = { + configRepo: IConfigRepository; + metadataRepo: ISystemMetadataRepository; + logger: ILoggerRepository; +}; + +const asyncLock = new AsyncLock(); +let config: SystemConfig | null = null; +let lastUpdated: number | null = null; + +export const clearConfigCache = () => { + config = null; + lastUpdated = null; +}; + +export const getConfig = async (repos: RepoDeps, { withCache }: { withCache: boolean }): Promise => { + if (!withCache || !config) { + const timestamp = lastUpdated; + await asyncLock.acquire(DatabaseLock[DatabaseLock.GetSystemConfig], async () => { + if (timestamp === lastUpdated) { + config = await buildConfig(repos); + lastUpdated = Date.now(); + } + }); + } + + return config!; +}; + +export const updateConfig = async (repos: RepoDeps, newConfig: SystemConfig): Promise => { + const { metadataRepo } = repos; + // get the difference between the new config and the default config + const partialConfig: DeepPartial = {}; + for (const property of getKeysDeep(defaults)) { + const newValue = _.get(newConfig, property); + const isEmpty = newValue === undefined || newValue === null || newValue === ''; + const defaultValue = _.get(defaults, property); + const isEqual = newValue === defaultValue || _.isEqual(newValue, defaultValue); + + if (isEmpty || isEqual) { + continue; + } + + _.set(partialConfig, property, newValue); + } + + await metadataRepo.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + + return getConfig(repos, { withCache: false }); +}; + +const loadFromFile = async ({ metadataRepo, logger }: RepoDeps, filepath: string) => { + try { + const file = await metadataRepo.readFile(filepath); + return loadYaml(file.toString()) as unknown; + } catch (error: Error | any) { + logger.error(`Unable to load configuration file: ${filepath}`); + logger.error(error); + throw error; + } +}; + +const buildConfig = async (repos: RepoDeps) => { + const { configRepo, metadataRepo, logger } = repos; + const { configFile } = configRepo.getEnv(); + + // load partial + const partial = configFile + ? await loadFromFile(repos, configFile) + : await metadataRepo.get(SystemMetadataKey.SYSTEM_CONFIG); + + // merge with defaults + const config = _.cloneDeep(defaults); + for (const property of getKeysDeep(partial)) { + _.set(config, property, _.get(partial, property)); + } + + // check for extra properties + const unknownKeys = _.cloneDeep(config); + for (const property of getKeysDeep(defaults)) { + unsetDeep(unknownKeys, property); + } + + if (!_.isEmpty(unknownKeys)) { + logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`); + } + + // validate full config + const errors = await validate(plainToInstance(SystemConfigDto, config)); + if (errors.length > 0) { + if (configFile) { + throw new Error(`Invalid value(s) in file: ${errors}`); + } else { + logger.error('Validation error', errors); + } + } + + if (config.server.externalDomain.length > 0) { + config.server.externalDomain = new URL(config.server.externalDomain).origin; + } + + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { + config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); + } + + if (!config.ffmpeg.acceptedAudioCodecs.includes(config.ffmpeg.targetAudioCodec)) { + config.ffmpeg.acceptedAudioCodecs.push(config.ffmpeg.targetAudioCodec); + } + + return config; +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index f3232eb78bb2e..45fb6c6daef21 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface'; import { Between, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, SelectQueryBuilder } from 'typeorm'; @@ -80,7 +81,7 @@ export function searchAssetBuilder( }); } - const status = _.pick(options, ['isFavorite', 'isOffline', 'isVisible', 'type']); + const status = _.pick(options, ['isFavorite', 'isVisible', 'type']); const { isArchived, isEncoded, @@ -91,7 +92,6 @@ export function searchAssetBuilder( withPeople, withSmartInfo, personIds, - withExif, withStacked, trashedAfter, trashedBefore, @@ -120,7 +120,7 @@ export function searchAssetBuilder( } if (withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + builder.leftJoinAndSelect('faces.person', 'person'); } if (withSmartInfo) { @@ -128,15 +128,14 @@ export function searchAssetBuilder( } if (personIds && personIds.length > 0) { - builder - .leftJoin(`${builder.alias}.faces`, 'faces') - .andWhere('faces.personId IN (:...personIds)', { personIds }) - .addGroupBy(`${builder.alias}.id`) - .having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length }); - - if (withExif) { - builder.addGroupBy('exifInfo.assetId'); - } + const cte = builder + .createQueryBuilder() + .select('faces."assetId"') + .from(AssetFaceEntity, 'faces') + .where('faces."personId" IN (:...personIds)', { personIds }) + .groupBy(`faces."assetId"`) + .having(`COUNT(DISTINCT faces."personId") = :personCount`, { personCount: personIds.length }); + builder.addCommonTableExpression(cte, 'face_ids').innerJoin('face_ids', 'a', 'a."assetId" = asset.id'); } if (withStacked) { diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts deleted file mode 100644 index 064c9f75071ef..0000000000000 --- a/server/src/utils/events.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ModuleRef, Reflector } from '@nestjs/core'; -import _ from 'lodash'; -import { EmitConfig } from 'src/decorators'; -import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; -import { Metadata } from 'src/middleware/auth.guard'; -import { services } from 'src/services'; - -type Item = { - event: T; - handler: EmitHandler; - priority: number; - label: string; -}; - -export class ImmichStartupError extends Error {} -export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; - -export const setupEventHandlers = (moduleRef: ModuleRef) => { - const reflector = moduleRef.get(Reflector, { strict: false }); - const repository = moduleRef.get(IEventRepository); - const items: Item[] = []; - - // discovery - for (const Service of services) { - const instance = moduleRef.get(Service); - const ctx = Object.getPrototypeOf(instance); - for (const property of Object.getOwnPropertyNames(ctx)) { - const descriptor = Object.getOwnPropertyDescriptor(ctx, property); - if (!descriptor || descriptor.get || descriptor.set) { - continue; - } - - const handler = instance[property]; - if (typeof handler !== 'function') { - continue; - } - - const options = reflector.get(Metadata.ON_EMIT_CONFIG, handler); - if (!options) { - continue; - } - - items.push({ - event: options.event, - priority: options.priority || 0, - handler: handler.bind(instance), - label: `${Service.name}.${handler.name}`, - }); - } - } - - const handlers = _.orderBy(items, ['priority'], ['asc']); - - // register by priority - for (const { event, handler } of handlers) { - repository.on(event as EmitEvent, handler); - } - - return handlers; -}; diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 53a4d571dcfa3..3b26c3e1ba1e8 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -3,6 +3,7 @@ import { NextFunction, Response } from 'express'; import { access, constants } from 'node:fs/promises'; import { basename, extname, isAbsolute } from 'node:path'; import { promisify } from 'node:util'; +import { CacheControl } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream } from 'src/interfaces/storage.interface'; import { isConnectionAborted } from 'src/utils/misc'; @@ -19,12 +20,6 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string return getFileNameWithoutExtension(stillName) + extname(motionName); } -export enum CacheControl { - PRIVATE_WITH_CACHE = 'private_with_cache', - PRIVATE_WITHOUT_CACHE = 'private_without_cache', - NONE = 'none', -} - export class ImmichFileResponse { public readonly path!: string; public readonly contentType!: string; diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index d4eb02ead21ab..2e33a7bcb557a 100644 --- a/server/src/utils/logger.ts +++ b/server/src/utils/logger.ts @@ -2,24 +2,6 @@ import { HttpException } from '@nestjs/common'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { TypeORMError } from 'typeorm'; -type ColorTextFn = (text: string) => string; - -const isColorAllowed = () => !process.env.NO_COLOR; -const colorIfAllowed = (colorFn: ColorTextFn) => (text: string) => (isColorAllowed() ? colorFn(text) : text); - -export const LogColor = { - red: colorIfAllowed((text: string) => `\u001B[31m${text}\u001B[39m`), - green: colorIfAllowed((text: string) => `\u001B[32m${text}\u001B[39m`), - yellow: colorIfAllowed((text: string) => `\u001B[33m${text}\u001B[39m`), - blue: colorIfAllowed((text: string) => `\u001B[34m${text}\u001B[39m`), - magentaBright: colorIfAllowed((text: string) => `\u001B[95m${text}\u001B[39m`), - cyanBright: colorIfAllowed((text: string) => `\u001B[96m${text}\u001B[39m`), -}; - -export const LogStyle = { - bold: colorIfAllowed((text: string) => `\u001B[1m${text}\u001B[0m`), -}; - export const logGlobalError = (logger: ILoggerRepository, error: Error) => { if (error instanceof HttpException) { const status = error.getStatus(); diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 8068f4a5e6587..6f0ab4ef81d90 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,5 +1,5 @@ -import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/config'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/enum'; import { AudioStreamInfo, BitrateDistribution, @@ -80,6 +80,7 @@ export class BaseConfig implements VideoCodecSWConfig { inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), + progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, } as TranscodeCommand; if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { const filters = this.getFilterOptions(videoStream); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 47f3f552c47e7..9bea4e9585c55 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -11,10 +11,10 @@ import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { SystemConfig } from 'src/config'; -import { CLIP_MODEL_INFO, isDev, serverVersion } from 'src/constants'; +import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; +import { MetadataKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { Metadata } from 'src/middleware/auth.guard'; /** * @returns a list of strings representing the keys of the object in dot notation @@ -193,7 +193,7 @@ const patchOpenAPI = (document: OpenAPIObject) => { return document; }; -export const useSwagger = (app: INestApplication, force = false) => { +export const useSwagger = (app: INestApplication, { write }: { write: boolean }) => { const config = new DocumentBuilder() .setTitle('Immich') .setDescription('Immich API') @@ -210,7 +210,7 @@ export const useSwagger = (app: INestApplication, force = false) => { in: 'header', name: ImmichHeader.API_KEY, }, - Metadata.API_KEY_SECURITY, + MetadataKey.API_KEY_SECURITY, ) .addServer('/api') .build(); @@ -230,7 +230,7 @@ export const useSwagger = (app: INestApplication, force = false) => { SwaggerModule.setup('doc', app, specification, customOptions); - if (isDev() || force) { + if (write) { // Generate API Documentation only in development mode const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' }); diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index dec1a9de0c313..4009f219c1c96 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { PaginationMode } from 'src/enum'; import { FindManyOptions, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; export interface PaginationOptions { @@ -6,11 +7,6 @@ export interface PaginationOptions { skip?: number; } -export enum PaginationMode { - LIMIT_OFFSET = 'limit-offset', - SKIP_TAKE = 'skip-take', -} - export interface PaginatedBuilderOptions { take: number; skip?: number; diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts index 6d6c70f1d73ac..027afcf040195 100644 --- a/server/src/utils/tag.ts +++ b/server/src/utils/tag.ts @@ -8,7 +8,7 @@ export const upsertTags = async (repository: ITagRepository, { userId, tags }: U const results: TagEntity[] = []; for (const tag of tags) { - const parts = tag.split('/'); + const parts = tag.split('/').filter(Boolean); let parent: TagEntity | undefined; for (const part of parts) { diff --git a/server/src/utils/user.ts b/server/src/utils/user.ts new file mode 100644 index 0000000000000..c7029a1ecae4b --- /dev/null +++ b/server/src/utils/user.ts @@ -0,0 +1,35 @@ +import { BadRequestException } from '@nestjs/common'; +import sanitize from 'sanitize-filename'; +import { SALT_ROUNDS } from 'src/constants'; +import { UserEntity } from 'src/entities/user.entity'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IUserRepository } from 'src/interfaces/user.interface'; + +type RepoDeps = { userRepo: IUserRepository; cryptoRepo: ICryptoRepository }; + +export const createUser = async ( + { userRepo, cryptoRepo }: RepoDeps, + dto: Partial & { email: string }, +): Promise => { + const user = await userRepo.getByEmail(dto.email); + if (user) { + throw new BadRequestException('User exists'); + } + + if (!dto.isAdmin) { + const localAdmin = await userRepo.getAdmin(); + if (!localAdmin) { + throw new BadRequestException('The first registered account must the administrator.'); + } + } + + const payload: Partial = { ...dto }; + if (payload.password) { + payload.password = await cryptoRepo.hashBcrypt(payload.password, SALT_ROUNDS); + } + if (payload.storageLabel) { + payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); + } + + return userRepo.create(payload); +}; diff --git a/server/src/utils/workers.spec.ts b/server/src/utils/workers.spec.ts deleted file mode 100644 index 1e4ff5e2d3694..0000000000000 --- a/server/src/utils/workers.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getWorkers } from 'src/utils/workers'; - -describe('getWorkers', () => { - beforeEach(() => { - process.env.IMMICH_WORKERS_INCLUDE = ''; - process.env.IMMICH_WORKERS_EXCLUDE = ''; - }); - - it('should return default workers', () => { - expect(getWorkers()).toEqual(['api', 'microservices']); - }); - - it('should return included workers', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api'; - expect(getWorkers()).toEqual(['api']); - }); - - it('should excluded workers from defaults', () => { - process.env.IMMICH_WORKERS_EXCLUDE = 'api'; - expect(getWorkers()).toEqual(['microservices']); - }); - - it('should exclude workers from include list', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; - process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices'; - expect(getWorkers()).toEqual(['api']); - }); - - it('should remove whitespace from included workers before parsing', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices'; - expect(getWorkers()).toEqual(['api', 'microservices']); - }); - - it('should remove whitespace from excluded workers before parsing', () => { - process.env.IMMICH_WORKERS_EXCLUDE = 'api, microservices'; - expect(getWorkers()).toEqual([]); - }); - - it('should remove whitespace from included and excluded workers before parsing', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api, microservices, randomservice,randomservice2'; - process.env.IMMICH_WORKERS_EXCLUDE = 'randomservice,microservices, randomservice2'; - expect(getWorkers()).toEqual(['api']); - }); - - it('should throw error for invalid workers', () => { - process.env.IMMICH_WORKERS_INCLUDE = 'api,microservices,randomservice'; - expect(getWorkers).toThrowError('Invalid worker(s) found: api,microservices,randomservice'); - }); -}); diff --git a/server/src/utils/workers.ts b/server/src/utils/workers.ts deleted file mode 100644 index 14daa2620f833..0000000000000 --- a/server/src/utils/workers.ts +++ /dev/null @@ -1,21 +0,0 @@ -const WORKER_TYPES = new Set(['api', 'microservices']); - -export const getWorkers = () => { - let workers = ['api', 'microservices']; - const includedWorkers = process.env.IMMICH_WORKERS_INCLUDE?.replaceAll(/\s/g, ''); - const excludedWorkers = process.env.IMMICH_WORKERS_EXCLUDE?.replaceAll(/\s/g, ''); - - if (includedWorkers) { - workers = includedWorkers.split(','); - } - - if (excludedWorkers) { - workers = workers.filter((worker) => !excludedWorkers.split(',').includes(worker)); - } - - if (workers.some((worker) => !WORKER_TYPES.has(worker))) { - throw new Error(`Invalid worker(s) found: ${workers}`); - } - - return workers; -}; diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 629c50c653003..7535a902b80d6 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -5,11 +5,13 @@ import cookieParser from 'cookie-parser'; import { existsSync } from 'node:fs'; import sirv from 'sirv'; import { ApiModule } from 'src/app.module'; -import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/constants'; +import { excludePaths, serverVersion } from 'src/constants'; +import { ImmichEnvironment } from 'src/enum'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { ApiService } from 'src/services/api.service'; -import { isStartUpError } from 'src/utils/events'; +import { isStartUpError } from 'src/services/storage.service'; import { otelStart } from 'src/utils/instrumentation'; import { useSwagger } from 'src/utils/misc'; @@ -30,22 +32,24 @@ async function bootstrap() { otelStart(otelPort); - const port = Number(process.env.IMMICH_PORT) || 3001; const app = await NestFactory.create(ApiModule, { bufferLogs: true }); const logger = await app.resolve(ILoggerRepository); + const configRepository = app.get(IConfigRepository); + + const { environment, port, resourcePaths } = configRepository.getEnv(); + const isDev = environment === ImmichEnvironment.DEVELOPMENT; - logger.setAppName('Api'); logger.setContext('Bootstrap'); app.useLogger(logger); app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal', ...trustedProxies]); app.set('etag', 'strong'); app.use(cookieParser()); app.use(json({ limit: '10mb' })); - if (isDev()) { + if (isDev) { app.enableCors(); } app.useWebSocketAdapter(new WebSocketAdapter(app)); - useSwagger(app); + useSwagger(app, { write: isDev }); app.setGlobalPrefix('api', { exclude: excludePaths }); if (existsSync(resourcePaths.web.root)) { @@ -70,7 +74,7 @@ async function bootstrap() { const server = await (host ? app.listen(port, host) : app.listen(port)); server.requestTimeout = 30 * 60 * 1000; - logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); + logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `); } bootstrap().catch((error) => { diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts index 789b6f5287bbd..3cb478057ccd5 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -1,10 +1,11 @@ import { NestFactory } from '@nestjs/core'; import { isMainThread } from 'node:worker_threads'; import { MicroservicesModule } from 'src/app.module'; -import { envName, serverVersion } from 'src/constants'; +import { serverVersion } from 'src/constants'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; -import { isStartUpError } from 'src/utils/events'; +import { isStartUpError } from 'src/services/storage.service'; import { otelStart } from 'src/utils/instrumentation'; export async function bootstrap() { @@ -14,14 +15,15 @@ export async function bootstrap() { const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true }); const logger = await app.resolve(ILoggerRepository); - logger.setAppName('Microservices'); logger.setContext('Bootstrap'); app.useLogger(logger); app.useWebSocketAdapter(new WebSocketAdapter(app)); await app.listen(0); - logger.log(`Immich Microservices is running [v${serverVersion}] [${envName}] `); + const configRepository = app.get(IConfigRepository); + const { environment } = configRepository.getEnv(); + logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `); } if (!isMainThread) { diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 5ee42224ba862..119c0b6e5ab76 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -2,7 +2,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { StackEntity } from 'src/entities/stack.entity'; -import { AssetFileType, AssetType } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { libraryStub } from 'test/fixtures/library.stub'; @@ -42,6 +42,7 @@ export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity = export const assetStub = { noResizePath: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, originalFileName: 'IMG_123.jpg', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -69,13 +70,14 @@ export const assetStub = { faces: [], sidecarPath: null, deletedAt: null, - isOffline: false, isExternal: false, duplicateId: null, + isOffline: false, }), noWebpPath: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -83,7 +85,6 @@ export const assetStub = { ownerId: 'user-id', deviceId: 'device-id', originalPath: 'upload/library/IMG_456.jpg', - files: [previewFile], checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, @@ -103,17 +104,18 @@ export const assetStub = { originalFileName: 'IMG_456.jpg', faces: [], sidecarPath: null, - isOffline: false, isExternal: false, exifInfo: { fileSizeInByte: 123_000, } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), noThumbhash: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -131,7 +133,6 @@ export const assetStub = { localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, isArchived: false, - isOffline: false, duration: null, isVisible: true, isExternal: false, @@ -144,10 +145,12 @@ export const assetStub = { sidecarPath: null, deletedAt: null, duplicateId: null, + isOffline: false, }), primaryImage: Object.freeze({ id: 'primary-asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -170,7 +173,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -188,10 +190,12 @@ export const assetStub = { { id: 'stack-child-asset-2' } as AssetEntity, ]), duplicateId: null, + isOffline: false, }), image: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -214,7 +218,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -227,6 +230,7 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, }), trashed: Object.freeze({ @@ -254,7 +258,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -266,10 +269,13 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: false, + status: AssetStatus.TRASHED, }), - archived: Object.freeze({ + trashedOffline: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -284,20 +290,19 @@ export const assetStub = { encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'), + deletedAt: new Date('2023-02-24T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: true, + isFavorite: false, + isArchived: false, duration: null, isVisible: true, isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], - deletedAt: null, sidecarPath: null, exifInfo: { fileSizeInByte: 5000, @@ -305,49 +310,11 @@ export const assetStub = { exifImageWidth: 2160, } as ExifEntity, duplicateId: null, + isOffline: true, }), - - external: Object.freeze({ - id: 'asset-id', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('path hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: true, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - isOffline: false, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - }), - - offline: Object.freeze({ + archived: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -364,27 +331,30 @@ export const assetStub = { updatedAt: new Date('2023-02-23T05:06:29.716Z'), localDateTime: new Date('2023-02-23T05:06:29.716Z'), isFavorite: true, - isArchived: false, - isExternal: false, + isArchived: true, duration: null, isVisible: true, + isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: true, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], + deletedAt: null, sidecarPath: null, exifInfo: { fileSizeInByte: 5000, + exifImageHeight: 3840, + exifImageWidth: 2160, } as ExifEntity, - deletedAt: null, duplicateId: null, + isOffline: false, }), - externalOffline: Object.freeze({ + external: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -407,23 +377,24 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: true, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', faces: [], + deletedAt: null, sidecarPath: null, exifInfo: { fileSizeInByte: 5000, } as ExifEntity, - deletedAt: null, duplicateId: null, + isOffline: false, }), image1: Object.freeze({ id: 'asset-id-1', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -447,7 +418,6 @@ export const assetStub = { livePhotoVideo: null, livePhotoVideoId: null, isExternal: false, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.ext', @@ -457,10 +427,12 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageFrom2015: Object.freeze({ id: 'asset-id-1', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'), @@ -479,7 +451,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -494,10 +465,12 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), video: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, originalFileName: 'asset-id.ext', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -517,7 +490,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -533,9 +505,11 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), livePhotoMotionAsset: Object.freeze({ + status: AssetStatus.ACTIVE, id: fileStub.livePhotoMotion.uuid, originalPath: fileStub.livePhotoMotion.originalPath, ownerId: authStub.user1.user.id, @@ -551,6 +525,7 @@ export const assetStub = { liveMotionWithThumb: Object.freeze({ id: fileStub.livePhotoMotion.uuid, + status: AssetStatus.ACTIVE, originalPath: fileStub.livePhotoMotion.originalPath, ownerId: authStub.user1.user.id, type: AssetType.VIDEO, @@ -581,6 +556,7 @@ export const assetStub = { livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', + status: AssetStatus.ACTIVE, originalPath: fileStub.livePhotoStill.originalPath, ownerId: authStub.user1.user.id, type: AssetType.IMAGE, @@ -596,6 +572,7 @@ export const assetStub = { livePhotoStillAssetWithTheSameLivePhotoMotionAsset: Object.freeze({ id: 'live-photo-still-asset-1', + status: AssetStatus.ACTIVE, originalPath: fileStub.livePhotoStill.originalPath, ownerId: authStub.user1.user.id, type: AssetType.IMAGE, @@ -611,6 +588,7 @@ export const assetStub = { livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', + status: AssetStatus.ACTIVE, originalPath: fileStub.livePhotoStill.originalPath, originalFileName: fileStub.livePhotoStill.originalName, ownerId: authStub.user1.user.id, @@ -627,6 +605,7 @@ export const assetStub = { withLocation: Object.freeze({ id: 'asset-with-favorite-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-22T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-22T05:06:29.716Z'), @@ -646,7 +625,6 @@ export const assetStub = { isFavorite: false, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -665,9 +643,11 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), sidecar: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -686,7 +666,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -698,9 +677,11 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, duplicateId: null, + isOffline: false, }), sidecarWithoutExt: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -719,7 +700,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -731,44 +711,12 @@ export const assetStub = { sidecarPath: '/original/path.xmp', deletedAt: null, duplicateId: null, - }), - - readOnly: Object.freeze({ - id: 'read-only-asset', - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files: [previewFile], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: false, isOffline: false, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - tags: [], - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - sidecarPath: '/original/path.ext.xmp', - deletedAt: null, - duplicateId: null, }), hasEncodedVideo: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, originalFileName: 'asset-id.ext', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -788,7 +736,6 @@ export const assetStub = { isFavorite: true, isArchived: false, isExternal: false, - isOffline: false, duration: null, isVisible: true, livePhotoVideo: null, @@ -802,9 +749,11 @@ export const assetStub = { } as ExifEntity, deletedAt: null, duplicateId: null, + isOffline: false, }), missingFileExtension: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -827,7 +776,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -840,9 +788,11 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasFileExtension: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -865,7 +815,6 @@ export const assetStub = { isVisible: true, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, libraryId: 'library-id', library: libraryStub.externalLibrary1, tags: [], @@ -878,9 +827,11 @@ export const assetStub = { fileSizeInByte: 5000, } as ExifEntity, duplicateId: null, + isOffline: false, }), imageDng: Object.freeze({ id: 'asset-id', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -903,7 +854,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -916,9 +866,11 @@ export const assetStub = { bitsPerSample: 14, } as ExifEntity, duplicateId: null, + isOffline: false, }), hasEmbedding: Object.freeze({ id: 'asset-id-embedding', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -941,7 +893,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -956,9 +907,11 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), hasDupe: Object.freeze({ id: 'asset-id-dupe', + status: AssetStatus.ACTIVE, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -981,7 +934,6 @@ export const assetStub = { isExternal: false, livePhotoVideo: null, livePhotoVideoId: null, - isOffline: false, tags: [], sharedLinks: [], originalFileName: 'asset-id.jpg', @@ -996,5 +948,6 @@ export const assetStub = { assetId: 'asset-id', embedding: Array.from({ length: 512 }, Math.random), }, + isOffline: false, }), }; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 27ca2a4356e22..e8c4592b8bac7 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -141,4 +141,32 @@ export const faceStub = { sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId9', embedding: [1, 2, 3, 4] }, }), + fromExif1: Object.freeze({ + id: 'assetFaceId9', + assetId: assetStub.image.id, + asset: assetStub.image, + personId: personStub.randomPerson.id, + person: personStub.randomPerson, + boundingBoxX1: 100, + boundingBoxY1: 100, + boundingBoxX2: 200, + boundingBoxY2: 200, + imageHeight: 500, + imageWidth: 400, + sourceType: SourceType.EXIF, + }), + fromExif2: Object.freeze({ + id: 'assetFaceId9', + assetId: assetStub.image.id, + asset: assetStub.image, + personId: personStub.randomPerson.id, + person: personStub.randomPerson, + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + sourceType: SourceType.EXIF, + }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 54898d8693e75..f237e1dea942c 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -5,7 +5,7 @@ import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto'; import { mapUser } from 'src/dtos/user.dto'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { AssetOrder, AssetType, SharedLinkType } from 'src/enum'; +import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { userStub } from 'test/fixtures/user.stub'; @@ -188,6 +188,7 @@ export const sharedLinkStub = { assets: [ { id: 'id_1', + status: AssetStatus.ACTIVE, owner: undefined as unknown as UserEntity, ownerId: 'user_id_1', deviceAssetId: 'device_asset_id_1', diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/metadata.service.spec.ts new file mode 100644 index 0000000000000..3ccce0f16e725 --- /dev/null +++ b/server/test/medium/metadata.service.spec.ts @@ -0,0 +1,137 @@ +import { Stats } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { MetadataRepository } from 'src/repositories/metadata.repository'; +import { MetadataService } from 'src/services/metadata.service'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newRandomImage, newTestService } from 'test/utils'; +import { Mocked } from 'vitest'; + +const metadataRepository = new MetadataRepository(newLoggerRepositoryMock()); + +const createTestFile = async (exifData: Record) => { + const data = newRandomImage(); + const filePath = join(tmpdir(), 'test.png'); + await writeFile(filePath, data); + await metadataRepository.writeTags(filePath, exifData); + return { filePath }; +}; + +type TimeZoneTest = { + description: string; + serverTimeZone?: string; + exifData: Record; + expected: { + localDateTime: string; + dateTimeOriginal: string; + timeZone: string | null; + }; +}; + +describe(MetadataService.name, () => { + let sut: MetadataService; + + let assetMock: Mocked; + let storageMock: Mocked; + + beforeEach(() => { + ({ sut, assetMock, storageMock } = newTestService(MetadataService, { metadataRepository })); + + storageMock.stat.mockResolvedValue({ size: 123_456 } as Stats); + + delete process.env.TZ; + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('handleMetadataExtraction', () => { + const timeZoneTests: TimeZoneTest[] = [ + { + description: 'should handle no time zone information', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2022-01-01T00:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server behind UTC', + serverTimeZone: 'America/Los_Angeles', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2022-01-01T08:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server ahead of UTC', + serverTimeZone: 'Europe/Brussels', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2021-12-31T23:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle no time zone information and server ahead of UTC in the summer', + serverTimeZone: 'Europe/Brussels', + exifData: { + DateTimeOriginal: '2022:06:01 00:00:00', + }, + expected: { + localDateTime: '2022-06-01T00:00:00.000Z', + dateTimeOriginal: '2022-05-31T22:00:00.000Z', + timeZone: null, + }, + }, + { + description: 'should handle a +13:00 time zone', + exifData: { + DateTimeOriginal: '2022:01:01 00:00:00+13:00', + }, + expected: { + localDateTime: '2022-01-01T00:00:00.000Z', + dateTimeOriginal: '2021-12-31T11:00:00.000Z', + timeZone: 'UTC+13', + }, + }, + ]; + + it.each(timeZoneTests)('$description', async ({ exifData, serverTimeZone, expected }) => { + process.env.TZ = serverTimeZone ?? undefined; + + const { filePath } = await createTestFile(exifData); + assetMock.getByIds.mockResolvedValue([{ id: 'asset-1', originalPath: filePath } as AssetEntity]); + + await sut.handleMetadataExtraction({ id: 'asset-1' }); + + expect(assetMock.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ + dateTimeOriginal: new Date(expected.dateTimeOriginal), + timeZone: expected.timeZone, + }), + ); + + expect(assetMock.update).toHaveBeenCalledWith( + expect.objectContaining({ + localDateTime: new Date(expected.localDateTime), + }), + ); + }); + }); +}); diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index af267dd49cd7d..dd5c3af6a8d9a 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -4,17 +4,14 @@ import { Mocked, vitest } from 'vitest'; export const newAlbumRepositoryMock = (): Mocked => { return { getById: vitest.fn(), - getByIds: vitest.fn(), getByAssetId: vitest.fn(), getMetadataForIds: vitest.fn(), - getInvalidThumbnail: vitest.fn(), getOwned: vitest.fn(), getShared: vitest.fn(), getNotShared: vitest.fn(), restoreAll: vitest.fn(), softDeleteAll: vitest.fn(), deleteAll: vitest.fn(), - getAll: vitest.fn(), addAssetIds: vitest.fn(), removeAsset: vitest.fn(), removeAssetIds: vitest.fn(), diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 69f07bf1051c4..50fff31e55e4c 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -19,14 +19,12 @@ export const newAssetRepositoryMock = (): Mocked => { getUploadAssetIdByChecksum: vitest.fn(), getWith: vitest.fn(), getRandom: vitest.fn(), - getFirstAssetForAlbumId: vitest.fn(), getLastUpdatedAssetForAlbumId: vitest.fn(), getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAllByDeviceId: vitest.fn(), getLivePhotoCount: vitest.fn(), updateAll: vitest.fn(), updateDuplicates: vitest.fn(), - getExternalLibraryAssetPaths: vitest.fn(), getByLibraryIdAndOriginalPath: vitest.fn(), deleteAll: vitest.fn(), update: vitest.fn(), @@ -35,15 +33,12 @@ export const newAssetRepositoryMock = (): Mocked => { getStatistics: vitest.fn(), getTimeBucket: vitest.fn(), getTimeBuckets: vitest.fn(), - restoreAll: vitest.fn(), - softDeleteAll: vitest.fn(), getAssetIdByCity: vitest.fn(), getAssetIdByTag: vitest.fn(), getAllForUserFullSync: vitest.fn(), getChangedDeltaSync: vitest.fn(), getDuplicates: vitest.fn(), upsertFile: vitest.fn(), - getAssetsByOriginalPath: vitest.fn(), - getUniqueOriginalPaths: vitest.fn(), + upsertFiles: vitest.fn(), }; }; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts new file mode 100644 index 0000000000000..d44d50524a28a --- /dev/null +++ b/server/test/repositories/config.repository.mock.ts @@ -0,0 +1,58 @@ +import { ImmichEnvironment, ImmichWorker } from 'src/enum'; +import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { Mocked, vitest } from 'vitest'; + +const envData: EnvData = { + port: 3001, + environment: ImmichEnvironment.PRODUCTION, + + buildMetadata: {}, + + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + + skipMigrations: false, + vectorExtension: DatabaseExtension.VECTORS, + }, + + licensePublicKey: { + client: 'client-public-key', + server: 'server-public-key', + }, + + resourcePaths: { + lockFile: 'build-lock.json', + geodata: { + dateFile: '/build/geodata/geodata-date.txt', + admin1: '/build/geodata/admin1CodesASCII.txt', + admin2: '/build/geodata/admin2Codes.txt', + cities500: '/build/geodata/cities500.txt', + naturalEarthCountriesPath: 'build/ne_10m_admin_0_countries.geojson', + }, + web: { + root: '/build/www', + indexHtml: '/build/www/index.html', + }, + }, + + storage: { + ignoreMountCheckErrors: false, + }, + + workers: [ImmichWorker.API, ImmichWorker.MICROSERVICES], + + noColor: false, +}; + +export const newConfigRepositoryMock = (): Mocked => { + return { + getEnv: vitest.fn().mockReturnValue(envData), + }; +}; + +export const mockEnvData = (config: Partial) => ({ ...envData, ...config }); diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index a9af627599b60..23f5408005182 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -3,10 +3,11 @@ import { Mocked, vitest } from 'vitest'; export const newEventRepositoryMock = (): Mocked => { return { - on: vitest.fn(), + setup: vitest.fn(), + on: vitest.fn() as any, emit: vitest.fn() as any, - clientSend: vitest.fn(), - clientBroadcast: vitest.fn(), + clientSend: vitest.fn() as any, + clientBroadcast: vitest.fn() as any, serverSend: vitest.fn(), }; }; diff --git a/server/test/repositories/job.repository.mock.ts b/server/test/repositories/job.repository.mock.ts index 6bffe184fda57..871801830a81d 100644 --- a/server/test/repositories/job.repository.mock.ts +++ b/server/test/repositories/job.repository.mock.ts @@ -5,7 +5,6 @@ export const newJobRepositoryMock = (): Mocked => { return { addHandler: vitest.fn(), addCronJob: vitest.fn(), - deleteCronJob: vitest.fn(), updateCronJob: vitest.fn(), setConcurrency: vitest.fn(), empty: vitest.fn(), diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 5f7262c7e5d92..6342e9e73cc85 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -6,7 +6,7 @@ export const newLoggerRepositoryMock = (): Mocked => { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), - + isLevelEnabled: vitest.fn(), verbose: vitest.fn(), debug: vitest.fn(), log: vitest.fn(), diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index 4c344a986679f..a809b08162347 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -3,8 +3,9 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked => { return { - generateThumbnail: vitest.fn(), - generateThumbhash: vitest.fn(), + generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()), + decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }), extract: vitest.fn().mockResolvedValue(false), probe: vitest.fn(), transcode: vitest.fn(), diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 5dbfb3d453c32..60c5644b36673 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -7,10 +7,5 @@ export const newMetadataRepositoryMock = (): Mocked => { readTags: vitest.fn(), writeTags: vitest.fn(), extractBinaryTag: vitest.fn(), - getCameraMakes: vitest.fn(), - getCameraModels: vitest.fn(), - getCities: vitest.fn(), - getCountries: vitest.fn(), - getStates: vitest.fn(), }; }; diff --git a/server/test/repositories/notification.repository.mock.ts b/server/test/repositories/notification.repository.mock.ts index 71975b429c98e..16862dc3d762b 100644 --- a/server/test/repositories/notification.repository.mock.ts +++ b/server/test/repositories/notification.repository.mock.ts @@ -4,7 +4,7 @@ import { Mocked } from 'vitest'; export const newNotificationRepositoryMock = (): Mocked => { return { renderEmail: vitest.fn(), - sendEmail: vitest.fn(), + sendEmail: vitest.fn().mockResolvedValue({ messageId: 'message-1' }), verifySmtp: vitest.fn(), }; }; diff --git a/server/test/repositories/oauth.repository.mock.ts b/server/test/repositories/oauth.repository.mock.ts new file mode 100644 index 0000000000000..f87b3781e955f --- /dev/null +++ b/server/test/repositories/oauth.repository.mock.ts @@ -0,0 +1,11 @@ +import { IOAuthRepository } from 'src/interfaces/oauth.interface'; +import { Mocked } from 'vitest'; + +export const newOAuthRepositoryMock = (): Mocked => { + return { + init: vitest.fn(), + authorize: vitest.fn(), + getLogoutEndpoint: vitest.fn(), + getProfile: vitest.fn(), + }; +}; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 6547a543390a0..286c527038356 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -6,17 +6,18 @@ export const newPersonRepositoryMock = (): Mocked => { getById: vitest.fn(), getAll: vitest.fn(), getAllForUser: vitest.fn(), - getAssets: vitest.fn(), getAllWithoutFaces: vitest.fn(), getByName: vitest.fn(), getDistinctNames: vitest.fn(), create: vitest.fn(), + createAll: vitest.fn(), update: vitest.fn(), - deleteAll: vitest.fn(), + updateAll: vitest.fn(), delete: vitest.fn(), - deleteAllFaces: vitest.fn(), + deleteAll: vitest.fn(), + deleteFaces: vitest.fn(), getStatistics: vitest.fn(), getAllFaces: vitest.fn(), @@ -24,8 +25,9 @@ export const newPersonRepositoryMock = (): Mocked => { getRandomFace: vitest.fn(), reassignFaces: vitest.fn(), + unassignFaces: vitest.fn(), createFaces: vitest.fn(), - replaceFaces: vitest.fn(), + refreshFaces: vitest.fn(), getFaces: vitest.fn(), reassignFace: vitest.fn(), getFaceById: vitest.fn(), diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index fd244c6f5cccd..be0e753e30577 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -7,11 +7,17 @@ export const newSearchRepositoryMock = (): Mocked => { searchSmart: vitest.fn(), searchDuplicates: vitest.fn(), searchFaces: vitest.fn(), + searchRandom: vitest.fn(), upsert: vitest.fn(), searchPlaces: vitest.fn(), getAssetsByCity: vitest.fn(), deleteAllSearchEmbeddings: vitest.fn(), getDimensionSize: vitest.fn(), setDimensionSize: vitest.fn(), + getCameraMakes: vitest.fn(), + getCameraModels: vitest.fn(), + getCities: vitest.fn(), + getCountries: vitest.fn(), + getStates: vitest.fn(), }; }; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 5c2951e097b24..5226e0bb1e985 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -48,7 +48,9 @@ export const newStorageRepositoryMock = (reset = true): Mocked => { - if (reset) { - SystemConfigCore.reset(); - } - +export const newSystemMetadataRepositoryMock = (): Mocked => { + clearConfigCache(); return { get: vitest.fn() as any, set: vitest.fn(), diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a3fc0e77e0312..acc2b59f6d686 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -17,5 +17,6 @@ export const newTagRepositoryMock = (): Mocked => { addAssetIds: vitest.fn(), removeAssetIds: vitest.fn(), upsertAssetIds: vitest.fn(), + deleteEmptyTags: vitest.fn(), }; }; diff --git a/server/test/repositories/trash.repository.mock.ts b/server/test/repositories/trash.repository.mock.ts new file mode 100644 index 0000000000000..472b315b01410 --- /dev/null +++ b/server/test/repositories/trash.repository.mock.ts @@ -0,0 +1,11 @@ +import { ITrashRepository } from 'src/interfaces/trash.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newTrashRepositoryMock = (): Mocked => { + return { + empty: vitest.fn(), + restore: vitest.fn(), + restoreAll: vitest.fn(), + getDeletedIds: vitest.fn(), + }; +}; diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index 6071ae47fa83f..6362ab6a999ff 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -1,12 +1,7 @@ -import { UserCore } from 'src/cores/user.core'; import { IUserRepository } from 'src/interfaces/user.interface'; import { Mocked, vitest } from 'vitest'; -export const newUserRepositoryMock = (reset = true): Mocked => { - if (reset) { - UserCore.reset(); - } - +export const newUserRepositoryMock = (): Mocked => { return { get: vitest.fn(), getAdmin: vitest.fn(), diff --git a/server/test/repositories/version-history.repository.mock.ts b/server/test/repositories/version-history.repository.mock.ts new file mode 100644 index 0000000000000..7c35e316d3315 --- /dev/null +++ b/server/test/repositories/version-history.repository.mock.ts @@ -0,0 +1,10 @@ +import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newVersionHistoryRepositoryMock = (): Mocked => { + return { + getAll: vitest.fn().mockResolvedValue([]), + getLatest: vitest.fn(), + create: vitest.fn(), + }; +}; diff --git a/server/test/repositories/view.repository.mock.ts b/server/test/repositories/view.repository.mock.ts new file mode 100644 index 0000000000000..a002362ae78f9 --- /dev/null +++ b/server/test/repositories/view.repository.mock.ts @@ -0,0 +1,9 @@ +import { IViewRepository } from 'src/interfaces/view.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newViewRepositoryMock = (): Mocked => { + return { + getAssetsByOriginalPath: vitest.fn(), + getUniqueOriginalPaths: vitest.fn(), + }; +}; diff --git a/server/test/utils.ts b/server/test/utils.ts new file mode 100644 index 0000000000000..3b7e80994d62c --- /dev/null +++ b/server/test/utils.ts @@ -0,0 +1,205 @@ +import { PNG } from 'pngjs'; +import { IMetadataRepository } from 'src/interfaces/metadata.interface'; +import { BaseService } from 'src/services/base.service'; +import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock'; +import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock'; +import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; +import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; +import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock'; +import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; +import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; +import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; +import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; +import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; +import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; +import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock'; +import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; +import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock'; +import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; +import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; +import { newOAuthRepositoryMock } from 'test/repositories/oauth.repository.mock'; +import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; +import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; +import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; +import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; +import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; +import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock'; +import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock'; +import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock'; +import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; +import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock'; +import { newViewRepositoryMock } from 'test/repositories/view.repository.mock'; +import { Mocked } from 'vitest'; + +type RepositoryOverrides = { + metadataRepository: IMetadataRepository; +}; +type BaseServiceArgs = ConstructorParameters; +type Constructor> = { + new (...deps: Args): Type; +}; + +export const newTestService = ( + Service: Constructor, + overrides?: RepositoryOverrides, +) => { + const { metadataRepository } = overrides || {}; + + const accessMock = newAccessRepositoryMock(); + const loggerMock = newLoggerRepositoryMock(); + const cryptoMock = newCryptoRepositoryMock(); + const activityMock = newActivityRepositoryMock(); + const auditMock = newAuditRepositoryMock(); + const albumMock = newAlbumRepositoryMock(); + const albumUserMock = newAlbumUserRepositoryMock(); + const assetMock = newAssetRepositoryMock(); + const configMock = newConfigRepositoryMock(); + const databaseMock = newDatabaseRepositoryMock(); + const eventMock = newEventRepositoryMock(); + const jobMock = newJobRepositoryMock(); + const keyMock = newKeyRepositoryMock(); + const libraryMock = newLibraryRepositoryMock(); + const machineLearningMock = newMachineLearningRepositoryMock(); + const mapMock = newMapRepositoryMock(); + const mediaMock = newMediaRepositoryMock(); + const memoryMock = newMemoryRepositoryMock(); + const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked; + const metricMock = newMetricRepositoryMock(); + const moveMock = newMoveRepositoryMock(); + const notificationMock = newNotificationRepositoryMock(); + const oauthMock = newOAuthRepositoryMock(); + const partnerMock = newPartnerRepositoryMock(); + const personMock = newPersonRepositoryMock(); + const searchMock = newSearchRepositoryMock(); + const serverInfoMock = newServerInfoRepositoryMock(); + const sessionMock = newSessionRepositoryMock(); + const sharedLinkMock = newSharedLinkRepositoryMock(); + const stackMock = newStackRepositoryMock(); + const storageMock = newStorageRepositoryMock(); + const systemMock = newSystemMetadataRepositoryMock(); + const tagMock = newTagRepositoryMock(); + const trashMock = newTrashRepositoryMock(); + const userMock = newUserRepositoryMock(); + const versionHistoryMock = newVersionHistoryRepositoryMock(); + const viewMock = newViewRepositoryMock(); + + const sut = new Service( + loggerMock, + accessMock, + activityMock, + auditMock, + albumMock, + albumUserMock, + assetMock, + configMock, + cryptoMock, + databaseMock, + eventMock, + jobMock, + keyMock, + libraryMock, + machineLearningMock, + mapMock, + mediaMock, + memoryMock, + metadataMock, + metricMock, + moveMock, + notificationMock, + oauthMock, + partnerMock, + personMock, + searchMock, + serverInfoMock, + sessionMock, + sharedLinkMock, + stackMock, + storageMock, + systemMock, + tagMock, + trashMock, + userMock, + versionHistoryMock, + viewMock, + ); + + return { + sut, + accessMock, + loggerMock, + cryptoMock, + activityMock, + auditMock, + albumMock, + albumUserMock, + assetMock, + configMock, + databaseMock, + eventMock, + jobMock, + keyMock, + libraryMock, + machineLearningMock, + mapMock, + mediaMock, + memoryMock, + metadataMock, + metricMock, + moveMock, + notificationMock, + oauthMock, + partnerMock, + personMock, + searchMock, + serverInfoMock, + sessionMock, + sharedLinkMock, + stackMock, + storageMock, + systemMock, + tagMock, + trashMock, + userMock, + versionHistoryMock, + viewMock, + }; +}; + +const createPNG = (r: number, g: number, b: number) => { + const image = new PNG({ width: 1, height: 1 }); + image.data[0] = r; + image.data[1] = g; + image.data[2] = b; + image.data[3] = 255; + return PNG.sync.write(image); +}; + +function* newPngFactory() { + for (let r = 0; r < 255; r++) { + for (let g = 0; g < 255; g++) { + for (let b = 0; b < 255; b++) { + yield createPNG(r, g, b); + } + } + } +} + +const pngFactory = newPngFactory(); + +export const newRandomImage = () => { + const { value } = pngFactory.next(); + if (!value) { + throw new Error('Ran out of random asset data'); + } + + return value; +}; diff --git a/server/vitest.config.medium.mjs b/server/vitest.config.medium.mjs new file mode 100644 index 0000000000000..40dad8d6a5024 --- /dev/null +++ b/server/vitest.config.medium.mjs @@ -0,0 +1,17 @@ +import swc from 'unplugin-swc'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: './', + globals: true, + include: ['test/medium/**/*.spec.ts'], + server: { + deps: { + fallbackCJS: true, + }, + }, + }, + plugins: [swc.vite(), tsconfigPaths()], +}); diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index 3c0ea00c84c7a..df1a9a7654b19 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -6,14 +6,21 @@ export default defineConfig({ test: { root: './', globals: true, + include: ['src/**/*.spec.ts'], coverage: { provider: 'v8', include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'], + exclude: [ + 'src/services/*.spec.ts', + 'src/services/api.service.ts', + 'src/services/microservices.service.ts', + 'src/services/index.ts', + ], thresholds: { lines: 80, statements: 80, branches: 85, - functions: 85, + functions: 80, }, }, server: { diff --git a/web/.nvmrc b/web/.nvmrc index 3516580bbbc04..2a393af592b8c 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/web/Dockerfile b/web/Dockerfile index 4bc711e15ece5..19d8d890ab55e 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-alpine3.20@sha256:1a526b97cace6b4006256570efa1a29cd1fe4b96a5301f8d48e87c5139438a45 +FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 RUN apk add --no-cache tini USER node diff --git a/web/README.md b/web/README.md index e9693ceb01410..603c7ad64e720 100644 --- a/web/README.md +++ b/web/README.md @@ -2,4 +2,4 @@ This project uses the [SvelteKit](https://kit.svelte.dev/) web framework. Please refer to [the SvelteKit docs](https://kit.svelte.dev/docs) for information on getting started as a contributor to this project. In particular, it will help you navigate the project's code if you understand the basics of [SvelteKit routing](https://kit.svelte.dev/docs/routing). -When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [../server](the server project). +When developing locally, you will run a SvelteKit Node.js server. When this project is deployed to production, it is built as a SPA and deployed as part of [the server project](../server). diff --git a/web/package-lock.json b/web/package-lock.json index 0fe66f8832e7b..5b8c71ae56c66 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,17 +1,17 @@ { "name": "immich-web", - "version": "1.114.0", + "version": "1.117.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.114.0", + "version": "1.117.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@mapbox/mapbox-gl-rtl-text": "^0.2.3", + "@mapbox/mapbox-gl-rtl-text": "^0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -33,7 +33,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", - "@faker-js/faker": "^8.4.1", + "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.3.0", @@ -74,13 +74,13 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.114.0", + "version": "1.117.0", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.2", + "@types/node": "^20.16.10", "typescript": "^5.3.3" } }, @@ -657,6 +657,16 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/core": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.6.0.tgz", + "integrity": "sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", @@ -726,9 +736,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz", - "integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.11.1.tgz", + "integrity": "sha512-/qu+TWz8WwPWc7/HcIJKi+c+MOm46GdVaSlTTQcaqaL53+GsoA6MxWp5PtTx48qbSP7ylM1Kn7nhvkugfJvRSA==", "dev": true, "license": "MIT", "engines": { @@ -745,10 +755,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz", + "integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@faker-js/faker": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", - "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.0.3.tgz", + "integrity": "sha512-lWrrK4QNlFSU+13PL9jMbMKLJYXDFu3tQfayBsMXX7KL/GiQeqfB1CzHkqD5UHBUtPAuPo6XwGbMFNdVMZObRA==", "dev": true, "funding": [ { @@ -756,9 +779,10 @@ "url": "https://opencollective.com/fakerjs" } ], + "license": "MIT", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0", - "npm": ">=6.14.13" + "node": ">=18.0.0", + "npm": ">=9.0.0" } }, "node_modules/@formatjs/ecma402-abstract": { @@ -1421,9 +1445,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1446,13 +1471,6 @@ "geojson-rewind": "geojson-rewind" } }, - "node_modules/@mapbox/geojson-types": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", - "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", - "license": "ISC", - "peer": true - }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -1462,23 +1480,10 @@ } }, "node_modules/@mapbox/mapbox-gl-rtl-text": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz", - "integrity": "sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==", - "license": "BSD-2-Clause", - "peerDependencies": { - "mapbox-gl": ">=0.32.1 <2.0.0" - } - }, - "node_modules/@mapbox/mapbox-gl-supported": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", - "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", - "license": "BSD-3-Clause", - "peer": true, - "peerDependencies": { - "mapbox-gl": ">=0.32.1 <2.0.0" - } + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.3.0.tgz", + "integrity": "sha512-OwQplFqAAEYRobrTKm2wiVP+wcpUVlgXXiUMNQ8tcm5gPN5SQRXFADmITdQOaec4LhDhuuFchS7TS8ua8dUl4w==", + "license": "BSD-2-Clause" }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", @@ -1538,6 +1543,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/@namnode/store/-/store-0.1.0.tgz", "integrity": "sha512-4NGTldxKcmY0UuZ7OEkvCjs8ZEoeYB6M2UwMu74pdLiFMKxXbj9HdNk1Qn213bxX1O7bY5h+PLh5DZsTURZkYA==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/willnguyen1312" @@ -1579,30 +1585,30 @@ } }, "node_modules/@photo-sphere-viewer/core": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.9.0.tgz", - "integrity": "sha512-Th8S2SbKpKEE5l150Mh0Na+3RirceJL9ioRl+33kE59s0Dx675snGWI7gy/xFKEWsdYOhj9f6xNWZ8MSqs8RhQ==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.10.1.tgz", + "integrity": "sha512-xdvPbfQqLl8tggqNDMcczbQGNQHbA4N9u3RCuzYzhrN2PBE2ihL5TsH85smkH4GcRrJKypSzwXF7rDrQhcs7aQ==", "license": "MIT", "dependencies": { - "three": "^0.167.0" + "three": "^0.168.0" } }, "node_modules/@photo-sphere-viewer/equirectangular-video-adapter": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.9.0.tgz", - "integrity": "sha512-mQPnuKQPQvtNKMtjY8M3b6ANupA7soSDDLL/R8igtlP9vGMPgbVzPmGbrkyq6Ed2bQr+u8j2LkT38ztZ70Ingg==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.10.1.tgz", + "integrity": "sha512-159vPvsqPJ2prxnWpRH8QSaT+QlCOIac8XmhmkfwBoMqTZ8B1P+JWyuKYaDpqz4Bk/K+kncVBMNVvdro6bIccw==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.9.0" + "@photo-sphere-viewer/core": "5.10.1" } }, "node_modules/@photo-sphere-viewer/video-plugin": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.9.0.tgz", - "integrity": "sha512-u1li4KEO7iRMhlLWZsn55Jprb8LdSyFbisvHvk75wcSLGZIZj24vabogPrDtdiXuELaC1DTD6En9IpVD/H+mGQ==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.10.1.tgz", + "integrity": "sha512-rIoXvHuuB+qg8I0wqbe4S3YUXiliN4kTeV5Xx70dyPFtroGVEKpbvBZ8ygy029np0xALMkeKvi6cYiB40Lj1Pw==", "license": "MIT", "peerDependencies": { - "@photo-sphere-viewer/core": "5.9.0" + "@photo-sphere-viewer/core": "5.10.1" } }, "node_modules/@pkgjs/parseargs": { @@ -1879,9 +1885,9 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.4.tgz", - "integrity": "sha512-Qm4GAHCnRXwfWG9/AtnQ7mqjyjTs7i0Opyb8H2KH9rMR7fLxqiPx/tXeoE6HHo66+72CjyOb4nFH3lrejY4vzA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.5.tgz", + "integrity": "sha512-kFJR7RxeB6FBvrKZWAEzIALatgy11ISaaZbcPup8JdWUdrmmfUHHTJ738YHJTEfnCiiXi6aX8Q6ePY7tnSMD6Q==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1889,9 +1895,9 @@ } }, "node_modules/@sveltejs/enhanced-img": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.4.tgz", - "integrity": "sha512-eX+ob5uWr0bTLMKeG9nhhM84aR88hqiLiyEfWZPX7ijhk/wlmYSUX9nOiaVHh2ct1U+Ju9Hhb90Copw+ZNOB8w==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@sveltejs/enhanced-img/-/enhanced-img-0.3.8.tgz", + "integrity": "sha512-n66u46ZeqHltiTm0BEjWptYmCrCY0EltEEvakmC7d5o5ZejDbOvOWm914mebbRKaP2Bezv65TNCod/wqvw/0KA==", "dev": true, "license": "MIT", "dependencies": { @@ -1905,16 +1911,16 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.25", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.25.tgz", - "integrity": "sha512-5hBSEN8XEjDZ5+2bHkFh8Z0QyOk0C187cyb12aANe1c8aeKbfu5ZD5XaC2vEH4h0alJFDXPdUkXQBmeeXeMr1A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.6.1.tgz", + "integrity": "sha512-QFlch3GPGZYidYhdRAub0fONw8UTguPICFHUSPxNkA/jdlU1p6C6yqq19J1QWdxIHS2El/ycDCGrHb3EAiMNqg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", - "devalue": "^5.0.0", + "devalue": "^5.1.0", "esm-env": "^1.0.0", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", @@ -2170,9 +2176,9 @@ } }, "node_modules/@testing-library/svelte": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.1.tgz", - "integrity": "sha512-yXSqBsYaQAeP2xt7gqKu135Q67+NTsBDcpL1akv5MVAQ/amb7AQ0zW5nzrquTIE2lvrc6q58KZhQA61Vc05ZOg==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.3.tgz", + "integrity": "sha512-y5eaD2Vp3hb729dAv3dOYNoZ9uNM0bQ0rd5AfXDWPvI+HiGmjl8ZMOuKzBopveyAkci1Kplb6kS53uZhPGEK+w==", "dev": true, "license": "MIT", "dependencies": { @@ -2244,6 +2250,13 @@ "@types/geojson": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/justified-layout": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@types/justified-layout/-/justified-layout-4.1.4.tgz", @@ -2322,17 +2335,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz", - "integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", + "integrity": "sha512-wORFWjU30B2WJ/aXBfOm1LX9v9nyt9D3jsSOxC3cCaTQGCW5k4jNpmjFv3U7p/7s4yvdjHzwtv2Sd2dOyhjS0A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/type-utils": "8.3.0", - "@typescript-eslint/utils": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/type-utils": "8.8.0", + "@typescript-eslint/utils": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2356,16 +2369,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz", - "integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.0.tgz", + "integrity": "sha512-uEFUsgR+tl8GmzmLjRqz+VrDv4eoaMqMXW7ruXfgThaAShO9JTciKpEsB+TvnfFfbg5IpujgMXVV36gOJRLtZg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4" }, "engines": { @@ -2385,14 +2398,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz", - "integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz", + "integrity": "sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2403,14 +2416,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz", - "integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.0.tgz", + "integrity": "sha512-IKwJSS7bCqyCeG4NVGxnOP6lLT9Okc3Zj8hLO96bpMkJab+10HIfJbMouLrlpyOr3yrQ1cA413YPFiGd1mW9/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.3.0", - "@typescript-eslint/utils": "8.3.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2428,9 +2441,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz", - "integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.0.tgz", + "integrity": "sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw==", "dev": true, "license": "MIT", "engines": { @@ -2442,14 +2455,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz", - "integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz", + "integrity": "sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/visitor-keys": "8.3.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2496,30 +2509,17 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz", - "integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.0.tgz", + "integrity": "sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.3.0", - "@typescript-eslint/types": "8.3.0", - "@typescript-eslint/typescript-estree": "8.3.0" + "@typescript-eslint/scope-manager": "8.8.0", + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/typescript-estree": "8.8.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2533,13 +2533,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz", - "integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==", + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz", + "integrity": "sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.3.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2551,19 +2551,20 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz", - "integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.2.tgz", + "integrity": "sha512-b7kHrFrs2urS0cOk5N10lttI8UdJ/yP3nB4JYTREvR5o18cR99yPpK4gK8oQgI42BVv0ILWYUSYB7AXkAUDc0g==", "dev": true, + "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", - "debug": "^4.3.5", + "debug": "^4.3.6", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "magicast": "^0.3.4", "std-env": "^3.7.0", "test-exclude": "^7.0.1", @@ -2573,17 +2574,24 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "2.0.5" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, "node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.2.tgz", + "integrity": "sha512-FEgtlN8mIUSEAAnlvn7mP8vzaWhEaAEvhSXCqrsijM7K6QqjB11qoRZYEd4AKSCDz8p0/+yH5LzhZ47qt+EyPg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2591,11 +2599,40 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.2.tgz", + "integrity": "sha512-ExElkCGMS13JAJy+812fw1aCv2QO/LBK6CyO4WOPAzLTmve50gydOlWhgdBJPx2ztbADUq3JVI0C5U+bShaeEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "^2.1.0-beta.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.11" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/spy": "2.1.2", + "msw": "^2.3.5", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.2.tgz", + "integrity": "sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==", "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^1.2.0" }, @@ -2604,12 +2641,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz", - "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.2.tgz", + "integrity": "sha512-UCsPtvluHO3u7jdoONGjOSil+uON5SSvU9buQh3lP7GgUXHp78guN1wRmZDX4wGK6J10f9NUtP6pO+SFquoMlw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "2.0.5", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -2617,13 +2655,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz", - "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.2.tgz", + "integrity": "sha512-xtAeNsZ++aRIYIUsek7VHzry/9AcxeULlegBvsdLncLmNCR6tR8SRjn8BbDP4naxtccvzTqZ+L1ltZlRCfBZFA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "magic-string": "^0.30.10", + "@vitest/pretty-format": "2.1.2", + "magic-string": "^0.30.11", "pathe": "^1.1.2" }, "funding": { @@ -2631,10 +2670,11 @@ } }, "node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.2.tgz", + "integrity": "sha512-GSUi5zoy+abNRJwmFhBDC0yRuVUn8WMlQscvnbbXdKLXX9dE59YbfwXxuJ/mth6eeqIzofU8BB5XDo/Ns/qK2A==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^3.0.0" }, @@ -2643,13 +2683,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.2.tgz", + "integrity": "sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2658,9 +2698,9 @@ } }, "node_modules/@zoom-image/core": { - "version": "0.36.2", - "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.36.2.tgz", - "integrity": "sha512-NtqIA82xJUtTS8RMey3VUGF/q1tjkFZZUAR6edGdtiy43xIV7a239uuxomuU94WBkBtFztXL/ieyxxL8iPiyFg==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.38.0.tgz", + "integrity": "sha512-rA6/qTGfsRtWRs+WfMF0dIs+Ft9GBFusxXzEqqFsQa/0iYtN0MmOiuKzXGYPcIFKTbmQW/qqk0afIBtWd9163g==", "license": "MIT", "dependencies": { "@namnode/store": "^0.1.0" @@ -2671,12 +2711,12 @@ } }, "node_modules/@zoom-image/svelte": { - "version": "0.2.19", - "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.19.tgz", - "integrity": "sha512-mnQ8eEmUkGi/UolaReQJmEsQu7DmX+8Y+5cdcS6nHmIM/LZImClB53/AySjJym+y5ZbDLUOOc7phgijTkPYz9g==", + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.22.tgz", + "integrity": "sha512-lExo4M511/HtkmCsBzV5f8ABs8bEMZGtIrwl1pJro77iJ+5j9Yt7KUlPs6o+Yp028T6fqGJUsOCxCNWNZn9BIg==", "license": "MIT", "dependencies": { - "@zoom-image/core": "0.36.2" + "@zoom-image/core": "0.38.0" }, "funding": { "type": "github", @@ -2810,6 +2850,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -2986,6 +3027,7 @@ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3034,6 +3076,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz", "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==", "dev": true, + "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -3064,6 +3107,7 @@ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 16" } @@ -3328,13 +3372,6 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, - "node_modules/csscolorparser": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", - "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", - "license": "MIT", - "peer": true - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3439,6 +3476,7 @@ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3495,10 +3533,11 @@ } }, "node_modules/devalue": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz", - "integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==", - "dev": true + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", + "dev": true, + "license": "MIT" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -3560,35 +3599,16 @@ "dev": true }, "node_modules/engine.io-client": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.2.tgz", - "integrity": "sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==", + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz", + "integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==", + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0", - "xmlhttprequest-ssl": "~2.0.0" - } - }, - "node_modules/engine.io-client/node_modules/ws": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", - "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" } }, "node_modules/engine.io-parser": { @@ -3729,20 +3749,24 @@ } }, "node_modules/eslint": { - "version": "9.9.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz", - "integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==", + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.11.1.tgz", + "integrity": "sha512-MobhYKIoAO1s1e4VUrgx1l1Sk2JBR/Gqjjgw8+mfgoLE2xwsHur4gdfTxyTgShrhvdVFTaJSgMiQBl1jv/AWxg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.6.0", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.9.1", + "@eslint/js": "9.11.1", + "@eslint/plugin-kit": "^0.2.0", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.3.0", "@nodelib/fs.walk": "^1.2.8", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -3762,7 +3786,6 @@ "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", @@ -3804,39 +3827,6 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-compat-utils/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/eslint-compat-utils/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/eslint-config-prettier": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", @@ -3850,9 +3840,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.43.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.43.0.tgz", - "integrity": "sha512-REkxQWvg2pp7QVLxQNa+dJ97xUqRe7Y2JJbSWkHSuszu0VcblZtXkPBPckkivk99y5CdLw4slqfPylL2d/X4jQ==", + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.44.1.tgz", + "integrity": "sha512-w6wkoJPw1FJKFtM/2oln21rlu5+HTd2CSkkzhm32A+trNoW2EYQqTQAbDTU6k2GI/6Vh64rBHYQejqEgDld7fw==", "dev": true, "license": "MIT", "dependencies": { @@ -3866,7 +3856,7 @@ "postcss-safe-parser": "^6.0.0", "postcss-selector-parser": "^6.1.0", "semver": "^7.6.2", - "svelte-eslint-parser": "^0.41.0" + "svelte-eslint-parser": "^0.41.1" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -3884,19 +3874,6 @@ } } }, - "node_modules/eslint-plugin-svelte/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-unicorn": { "version": "55.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-55.0.0.tgz", @@ -3944,19 +3921,6 @@ "node": ">=6" } }, - "node_modules/eslint-plugin-unicorn/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -3985,6 +3949,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4052,9 +4023,9 @@ } }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.2.tgz", - "integrity": "sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", + "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -4069,9 +4040,9 @@ } }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz", - "integrity": "sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", + "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -4082,15 +4053,15 @@ } }, "node_modules/eslint/node_modules/espree": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.1.0.tgz", - "integrity": "sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", + "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.12.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.0.0" + "eslint-visitor-keys": "^4.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4218,41 +4189,6 @@ "es5-ext": "~0.10.14" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", @@ -4493,15 +4429,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4563,9 +4490,9 @@ } }, "node_modules/globals": { - "version": "15.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.9.0.tgz", - "integrity": "sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==", + "version": "15.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.10.0.tgz", + "integrity": "sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ==", "dev": true, "license": "MIT", "engines": { @@ -4591,13 +4518,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/grid-index": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", - "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", - "license": "ISC", - "peer": true - }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -4695,15 +4615,6 @@ "node": ">= 14" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5000,18 +4911,6 @@ "@types/estree": "*" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -5342,13 +5241,11 @@ "dev": true }, "node_modules/loupe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz", - "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-queue": { "version": "0.1.0", @@ -5377,12 +5274,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/magicast": { @@ -5411,90 +5308,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mapbox-gl": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", - "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", - "license": "SEE LICENSE IN LICENSE.txt", - "peer": true, - "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", - "@mapbox/geojson-types": "^1.0.2", - "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/mapbox-gl-supported": "^1.5.0", - "@mapbox/point-geometry": "^0.1.0", - "@mapbox/tiny-sdf": "^1.1.1", - "@mapbox/unitbezier": "^0.0.0", - "@mapbox/vector-tile": "^1.3.1", - "@mapbox/whoots-js": "^3.1.0", - "csscolorparser": "~1.0.3", - "earcut": "^2.2.2", - "geojson-vt": "^3.2.1", - "gl-matrix": "^3.2.1", - "grid-index": "^1.1.0", - "murmurhash-js": "^1.0.0", - "pbf": "^3.2.1", - "potpack": "^1.0.1", - "quickselect": "^2.0.0", - "rw": "^1.3.3", - "supercluster": "^7.1.0", - "tinyqueue": "^2.0.3", - "vt-pbf": "^3.1.1" - }, - "engines": { - "node": ">=6.4.0" - } - }, - "node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", - "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", - "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/mapbox-gl/node_modules/kdbush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", - "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", - "license": "ISC", - "peer": true - }, - "node_modules/mapbox-gl/node_modules/potpack": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", - "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", - "license": "ISC", - "peer": true - }, - "node_modules/mapbox-gl/node_modules/supercluster": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", - "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", - "license": "ISC", - "peer": true, - "dependencies": { - "kdbush": "^3.0.0" - } - }, "node_modules/maplibre-gl": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.0.1.tgz", @@ -5558,12 +5371,6 @@ "node": ">=0.12" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5611,18 +5418,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5779,33 +5574,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/nwsapi": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", @@ -5841,21 +5609,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -6038,13 +5791,15 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/pathval": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 14.16" } @@ -6072,9 +5827,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true, "license": "ISC" }, @@ -6127,9 +5882,9 @@ } }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -6148,8 +5903,8 @@ "license": "MIT", "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -6333,21 +6088,17 @@ } }, "node_modules/prettier-plugin-organize-imports": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.0.0.tgz", - "integrity": "sha512-vnKSdgv9aOlqKeEFGhf9SCBsTyzDSyScy1k7E0R1Uo4L0cTcOV7c1XQaT7jfXIOc/p08WLBfN2QUQA9zDSZMxA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", "dev": true, "license": "MIT", "peerDependencies": { - "@vue/language-plugin-pug": "^2.0.24", "prettier": ">=2.0", "typescript": ">=2.9", - "vue-tsc": "^2.0.24" + "vue-tsc": "^2.1.0" }, "peerDependenciesMeta": { - "@vue/language-plugin-pug": { - "optional": true - }, "vue-tsc": { "optional": true } @@ -6366,9 +6117,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.6.tgz", - "integrity": "sha512-Y1XWLw7vXUQQZmgv1JAEiLcErqUniAF2wO7QJsw8BVMvpLET2dI5WpEIEJx1r11iHVdSMzQxivyfrH9On9t2IQ==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.7.tgz", + "integrity": "sha512-/Dswx/ea0lV34If1eDcG3nulQ63YNr5KPDfMsjbdtpSWOxKKJ7nAc2qlVuYwEvCr4raIuredNoR7K4JCkmTGaQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6827,6 +6578,19 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", @@ -6899,39 +6663,6 @@ "@img/sharp-win32-x64": "0.33.3" } }, - "node_modules/sharp/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7001,13 +6732,14 @@ } }, "node_modules/socket.io-client": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", - "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.0.tgz", + "integrity": "sha512-C0jdhD5yQahMws9alf/yvtsMGTaIDBnZ8Rb5HU56svyq0l5LIrGzIDZZD5pHQlmzxLuU91Gz+VpQMKgCTNYtkw==", + "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", - "engine.io-client": "~6.5.2", + "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" }, "engines": { @@ -7067,9 +6799,10 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -7216,18 +6949,6 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -7352,14 +7073,14 @@ } }, "node_modules/svelte-check": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.0.tgz", - "integrity": "sha512-QgKO6OQbee9B2dyWZgrGruS3WHKrUZ718Ug53nK45vamsx93Al3on6tOrxyCMVX+OMOLLlrenn7b2VAomePwxQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.4.tgz", + "integrity": "sha512-AcHWIPuZb1mh/jKoIrww0ebBPpAvwWd1bfXCnwC2dx4OkydNMaiG//+Xnry91RJMHFH7CiE+6Y2p332DRIaOXQ==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", - "chokidar": "^3.4.1", + "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" @@ -7375,10 +7096,26 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-check/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/svelte-check/node_modules/fdir": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.3.0.tgz", - "integrity": "sha512-QOnuT+BOtivR77wYvCWHfGt9s4Pz1VIMbD463vegT5MLqNXy8rYFT/lPVEqf/bhYeT6qmqrNHhsX+rWwe3rOCQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.0.tgz", + "integrity": "sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -7405,10 +7142,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/svelte-check/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/svelte-eslint-parser": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.0.tgz", - "integrity": "sha512-L6f4hOL+AbgfBIB52Z310pg1d2QjRqm7wy3kI1W6hhdhX5bvu7+f0R6w4ykp5HoDdzq+vGhIJmsisaiJDGmVfA==", + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.1.tgz", + "integrity": "sha512-08ndI6zTghzI8SuJAFpvMbA/haPSGn3xz19pjre19yYMw8Nw/wQJ2PrZBI/L8ijGTgtkWCQQiLLy+Z1tfaCwNA==", "dev": true, "license": "MIT", "dependencies": { @@ -7873,9 +7624,9 @@ } }, "node_modules/svelte-maplibre": { - "version": "0.9.13", - "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.13.tgz", - "integrity": "sha512-XHQFKE86dKQ0PqjPGZ97jcHi83XdQRa4RW3hXDqmuxJ4yi2yvawdbO1Y0b2raAemCVERTcIU9HYgx0TAvqJgrA==", + "version": "0.9.14", + "resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.9.14.tgz", + "integrity": "sha512-5HBvibzU/Uf3g8eEz4Hty5XAwoBhW9Tp7NQEvb80U/glR/M1IHyzUKss6XMq8Zbci2wtsASeoPc6dA5R4+0e0w==", "license": "MIT", "dependencies": { "d3-geo": "^3.1.0", @@ -7924,9 +7675,9 @@ "peer": true }, "node_modules/tailwindcss": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", - "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", + "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", "dev": true, "license": "MIT", "dependencies": { @@ -8011,9 +7762,9 @@ } }, "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", - "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", "dev": true, "license": "ISC", "bin": { @@ -8109,9 +7860,9 @@ } }, "node_modules/three": { - "version": "0.167.1", - "resolved": "https://registry.npmjs.org/three/-/three-0.167.1.tgz", - "integrity": "sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==", + "version": "0.168.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.168.0.tgz", + "integrity": "sha512-6m6jXtDwMJEK/GGMbAOTSAmxNdzKvvBzgd7q8bE/7Tr6m7PaBh5kKLrN7faWtlglXbzj7sVba48Idwx+NRsZXw==", "license": "MIT" }, "node_modules/thumbhash": { @@ -8138,10 +7889,18 @@ } }, "node_modules/tinybench": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", - "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz", + "integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==", + "dev": true, + "license": "MIT" }, "node_modules/tinypool": { "version": "1.0.0", @@ -8167,10 +7926,11 @@ } }, "node_modules/tinyspy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz", - "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -8278,9 +8038,9 @@ } }, "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8410,14 +8170,14 @@ } }, "node_modules/vite": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz", - "integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==", + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.41", + "postcss": "^8.4.43", "rollup": "^4.20.0" }, "bin": { @@ -8483,15 +8243,15 @@ } }, "node_modules/vite-node": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz", - "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.2.tgz", + "integrity": "sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==", "dev": true, + "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.3.5", + "debug": "^4.3.6", "pathe": "^1.1.2", - "tinyrainbow": "^1.2.0", "vite": "^5.0.0" }, "bin": { @@ -8519,29 +8279,30 @@ } }, "node_modules/vitest": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz", - "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, + "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@vitest/expect": "2.0.5", - "@vitest/pretty-format": "^2.0.5", - "@vitest/runner": "2.0.5", - "@vitest/snapshot": "2.0.5", - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", + "@vitest/expect": "2.1.2", + "@vitest/mocker": "2.1.2", + "@vitest/pretty-format": "^2.1.2", + "@vitest/runner": "2.1.2", + "@vitest/snapshot": "2.1.2", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", - "debug": "^4.3.5", - "execa": "^8.0.1", - "magic-string": "^0.30.10", + "debug": "^4.3.6", + "magic-string": "^0.30.11", "pathe": "^1.1.2", "std-env": "^3.7.0", - "tinybench": "^2.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.0", "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.0.5", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8556,8 +8317,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.0.5", - "@vitest/ui": "2.0.5", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, @@ -8801,12 +8562,10 @@ "dev": true }, "node_modules/ws": { - "version": "8.14.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", - "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", - "dev": true, - "optional": true, - "peer": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -8843,9 +8602,9 @@ "peer": true }, "node_modules/xmlhttprequest-ssl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", - "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz", + "integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==", "engines": { "node": ">=0.4.0" } diff --git a/web/package.json b/web/package.json index 4dddc36e41c2e..dfc0f4b2a55e5 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.114.0", + "version": "1.117.0", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", @@ -25,7 +25,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.8.0", - "@faker-js/faker": "^8.4.1", + "@faker-js/faker": "^9.0.0", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.3.0", @@ -67,7 +67,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@mapbox/mapbox-gl-rtl-text": "^0.2.3", + "@mapbox/mapbox-gl-rtl-text": "^0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -87,6 +87,6 @@ "thumbhash": "^0.1.1" }, "volta": { - "node": "20.17.0" + "node": "20.18.0" } } diff --git a/web/src/app.html b/web/src/app.html index 778375c1e142b..6fd02dc9f811b 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -13,6 +13,8 @@ + + %sveltekit.head%

+
Licença: AGPLv3 Discord -
-
+
+