diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 2c7d1708395e20..00000000000000 --- 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/release.yml b/.github/release.yml index 1d9764194c7409..c549ead475dbe2 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -4,6 +4,10 @@ changelog: labels: - changelog:breaking-change + - title: 🫥 Deprecated Changes + labels: + - changelog:deprecated + - title: 🔒 Security labels: - changelog:security diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index a86408eea891cd..7052fa6ef97c65 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.1 - name: Login to GitHub Container Registry uses: docker/login-action@v3 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8c7aeb020e7ff3..b33e7d5662a4e5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -124,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.1 - name: Login to Docker Hub # Only push to Docker Hub when making a release @@ -215,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.1 - name: Login to Docker Hub # Only push to Docker Hub when making a release diff --git a/.github/workflows/pr-label-validation.yml b/.github/workflows/pr-label-validation.yml index 1557b3d15cfbab..754d4096132b65 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/test.yml b/.github/workflows/test.yml index 24e3e086235f0d..064e3c27616f72 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/.gitignore b/.gitignore index 537e048be28370..e0544ad8d59257 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ mobile/openapi/.openapi-generator/FILES open-api/typescript-sdk/build mobile/android/fastlane/report.xml mobile/ios/fastlane/report.xml + +vite.config.js.timestamp-* diff --git a/Makefile b/Makefile index 349a5c5e920ef9..2096cf86df09cf 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/cli/.nvmrc b/cli/.nvmrc index 3516580bbbc04b..2a393af592b8cd 100644 --- a/cli/.nvmrc +++ b/cli/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/cli/Dockerfile b/cli/Dockerfile index b08aba9d3c2b57..7e141548721b94 100644 --- a/cli/Dockerfile +++ b/cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 AS core +FROM node:20.18.0-alpine3.20@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9 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 6d585658f8792b..7e6deb10e78687 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.22", + "version": "2.2.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.22", + "version": "2.2.25", "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.9", + "@types/node": "^20.16.11", "@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.116.2", + "version": "1.118.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.9", + "@types/node": "^20.16.11", "typescript": "^5.3.3" } }, @@ -1354,9 +1354,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", - "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", + "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": { @@ -1370,17 +1370,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz", - "integrity": "sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==", + "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.7.0", - "@typescript-eslint/type-utils": "8.7.0", - "@typescript-eslint/utils": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -1404,16 +1404,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.7.0.tgz", - "integrity": "sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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": { @@ -1433,14 +1433,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz", - "integrity": "sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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" @@ -1451,14 +1451,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz", - "integrity": "sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==", + "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.7.0", - "@typescript-eslint/utils": "8.7.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1476,9 +1476,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.7.0.tgz", - "integrity": "sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==", + "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": { @@ -1490,14 +1490,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz", - "integrity": "sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -1519,16 +1519,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.7.0.tgz", - "integrity": "sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.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" @@ -1542,13 +1542,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz", - "integrity": "sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==", + "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.7.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1560,9 +1560,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", - "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", + "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": { @@ -1583,8 +1583,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.1", - "vitest": "2.1.1" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1593,14 +1593,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", - "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", + "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.1.1", - "@vitest/utils": "2.1.1", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -1609,9 +1609,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", - "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "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": { @@ -1623,7 +1623,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.1", + "@vitest/spy": "2.1.2", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -1637,9 +1637,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", - "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", + "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": { @@ -1650,13 +1650,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", - "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", + "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.1.1", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -1664,13 +1664,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", - "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -1679,9 +1679,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", - "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", + "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": { @@ -1692,13 +1692,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", - "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -2498,6 +2498,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -2640,16 +2641,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, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2683,9 +2674,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": { @@ -3043,14 +3034,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, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "10.4.3", @@ -4239,9 +4227,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", - "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", + "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": { @@ -4281,19 +4269,19 @@ } }, "node_modules/vitest": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", - "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", + "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": { - "@vitest/expect": "2.1.1", - "@vitest/mocker": "2.1.1", - "@vitest/pretty-format": "^2.1.1", - "@vitest/runner": "2.1.1", - "@vitest/snapshot": "2.1.1", - "@vitest/spy": "2.1.1", - "@vitest/utils": "2.1.1", + "@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.6", "magic-string": "^0.30.11", @@ -4304,7 +4292,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.1", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4319,8 +4307,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.1", - "@vitest/ui": "2.1.1", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, diff --git a/cli/package.json b/cli/package.json index cee258bff5e215..218b47bad487aa 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.22", + "version": "2.2.25", "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.9", + "@types/node": "^20.16.11", "@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/cli/src/utils.spec.ts b/cli/src/utils.spec.ts index 0094b329b8e391..3e7e55fcb69e12 100644 --- a/cli/src/utils.spec.ts +++ b/cli/src/utils.spec.ts @@ -115,17 +115,7 @@ const tests: Test[] = [ '/albums/image3.jpg': true, }, }, - { - test: 'should support globbing paths', - options: { - pathsToCrawl: ['/photos*'], - }, - files: { - '/photos1/image1.jpg': true, - '/photos2/image2.jpg': true, - '/images/image3.jpg': false, - }, - }, + { test: 'should crawl a single path without trailing slash', options: { diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 67948e0bd211a9..7bbbb5615b6406 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -141,25 +141,21 @@ export const crawl = async (options: CrawlOptions): Promise => { } } - let searchPattern: string; - if (patterns.length === 1) { - searchPattern = patterns[0]; - } else if (patterns.length === 0) { + if (patterns.length === 0) { return crawledFiles; - } else { - searchPattern = '{' + patterns.join(',') + '}'; - } - - if (recursive) { - searchPattern = searchPattern + '/**/'; } - searchPattern = `${searchPattern}/*.{${extensions.join(',')}}`; + const searchPatterns = patterns.map((pattern) => { + let escapedPattern = pattern; + if (recursive) { + escapedPattern = escapedPattern + '/**'; + } + return `${escapedPattern}/*.{${extensions.join(',')}}`; + }); - const globbedFiles = await glob(searchPattern, { + const globbedFiles = await glob(searchPatterns, { absolute: true, caseSensitiveMatch: false, - onlyFiles: true, dot: includeHidden, ignore: [`**/${exclusionPattern}`], }); diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 066dc9c701b45d..552b4a8673e695 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -45,7 +45,6 @@ services: soft: 1048576 hard: 1048576 ports: - - 3001:3001 - 9230:9230 - 9231:9231 depends_on: @@ -103,7 +102,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 + 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 a6256b33b1e643..b02b0157806887 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -16,7 +16,7 @@ services: env_file: - .env ports: - - 2283:3001 + - 2283:2283 depends_on: - redis - database @@ -47,7 +47,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 restart: always @@ -71,7 +71,22 @@ services: interval: 5m start_interval: 30s start_period: 5m - command: ["postgres", "-c", "shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + command: + [ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'logging_collector=on', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'wal_compression=on', + ] restart: always # set IMMICH_METRICS=true in .env to enable metrics @@ -91,7 +106,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:11.2.1-ubuntu@sha256:b90c0fdc482913de7a55fe96539bf9e3c4fbcee835d0c2dffc59152bc3964ff7 + 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 eec723dc08dbba..979343364c12f1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -22,7 +22,7 @@ services: env_file: - .env ports: - - 2283:3001 + - '2283:2283' depends_on: - redis - database @@ -48,7 +48,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 + image: docker.io/redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 healthcheck: test: redis-cli ping || exit 1 restart: always @@ -69,7 +69,22 @@ services: interval: 5m start_interval: 30s start_period: 5m - command: ["postgres", "-c", "shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + command: + [ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'logging_collector=on', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'wal_compression=on', + ] restart: always volumes: diff --git a/docs/.nvmrc b/docs/.nvmrc index 3516580bbbc04b..2a393af592b8cd 100644 --- a/docs/.nvmrc +++ b/docs/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 860b1e1ce74261..9b5793054ba5e7 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -34,14 +34,15 @@ docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgre 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 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 ``` @@ -55,12 +56,13 @@ docker exec -t immich_postgres pg_dumpall --clean --if-exists --username=postgre 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 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 ``` @@ -103,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 @@ -197,7 +200,7 @@ When you turn off the storage template engine, it will leave the assets in `UPLO - Stored in `UPLOAD_LOCATION/profile/`. - **Thumbs Images:** - Preview images (blurred, small, large) for each asset and thumbnails for recognized faces. - - Stored in `UPLOCAD_LOCATION/thumbs/`. + - Stored in `UPLOAD_LOCATION/thumbs/`. - **Encoded Assets:** - Videos that have been re-encoded from the original for wider compatibility. The original is not removed. - Stored in `UPLOAD_LOCATION/encoded-video/`. diff --git a/docs/docs/administration/jobs-workers.md b/docs/docs/administration/jobs-workers.md index fb5ca7c059165b..fde39a2e3af7c2 100644 --- a/docs/docs/administration/jobs-workers.md +++ b/docs/docs/administration/jobs-workers.md @@ -22,7 +22,7 @@ Copy the entire `immich-server` block as a new service and make the following ch - container_name: immich_server ... - ports: -- - 2283:3001 +- - 2283:2283 + immich-microservices: + container_name: immich_microservices ``` diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 12cd7502a58579..2dc6990944682b 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -11,7 +11,7 @@ Unable to set `app.immich:///oauth-callback` as a valid redirect URI? See [Mobil Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including: - [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) -- [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/) +- [Authelia](https://www.authelia.com/integration/openid-connect/immich/) - [Okta](https://www.okta.com/openid-connect/) - [Google](https://developers.google.com/identity/openid-connect/openid-connect) diff --git a/docs/docs/administration/postgres-standalone.md b/docs/docs/administration/postgres-standalone.md index b5028c788e422c..798555975f74c3 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 c40fecbdc4c231..c167a10d7fbc5b 100644 --- a/docs/docs/administration/reverse-proxy.md +++ b/docs/docs/administration/reverse-proxy.md @@ -99,7 +99,7 @@ services: # 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 + traefik.http.services.immich.loadbalancer.server.port: 2283 ``` Keep in mind, that Traefik needs to communicate with the network where immich is in, usually done diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index deba45caccebc0..7f74140ac0dda0 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` @@ -89,16 +89,7 @@ immich-server: devices: - /dev/dri:/dev/dri volumes: - - ${UPLOAD_LOCATION}:/usr/src/app/upload - - /etc/localtime:/etc/localtime:ro - env_file: - - .env - ports: - - 2283:3001 - depends_on: - - redis - - database - restart: always + ... ``` Once this is done, you can continue to step 3 of "Basic Setup". diff --git a/docs/docs/features/ml-hardware-acceleration.md b/docs/docs/features/ml-hardware-acceleration.md index 9f2d33cc35d7c3..ca1cb8edb1e996 100644 --- a/docs/docs/features/ml-hardware-acceleration.md +++ b/docs/docs/features/ml-hardware-acceleration.md @@ -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/template-backup-script.md b/docs/docs/guides/template-backup-script.md index 03c1a7a02b3339..a0cd890a49745c 100644 --- a/docs/docs/guides/template-backup-script.md +++ b/docs/docs/guides/template-backup-script.md @@ -9,7 +9,7 @@ The database is saved to your Immich upload folder in the `database-backup` subd ### Prerequisites - Borg needs to be installed on your server as well as the remote machine. You can find instructions to install Borg [here](https://borgbackup.readthedocs.io/en/latest/installation.html). -- (Optional) To run this sript as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/). +- (Optional) To run this script as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/). - To run this script non-interactively, set up [passwordless ssh](https://www.redhat.com/sysadmin/passwordless-ssh) to your remote machine from your server. If you skipped the previous step, make sure this step is done from your root account. To initialize the borg repository, run the following commands once. diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 29549586d359e7..bb9b4d434c1c26 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -68,7 +68,7 @@ Information on the current workers can be found [here](/docs/administration/jobs | Variable | Description | Default | | :------------ | :------------- | :----------------------------------------: | | `IMMICH_HOST` | Listening host | `0.0.0.0` | -| `IMMICH_PORT` | Listening port | `3001` (server), `3003` (machine learning) | +| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | ## Database @@ -164,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. @@ -171,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/truenas.md b/docs/docs/install/truenas.md index 271cd52cabc954..ffb559ed1216b6 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/docusaurus.config.js b/docs/docusaurus.config.js index a94a54b60c81ad..16d654b46bc587 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.json b/docs/package.json index cdcdf534468842..b7fa64097d6e0f 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 6982853fade77a..7f4206c97baf17 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 d8273c67c21794..3a034e3a04cfda 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 00000000000000..112ed1d70fa85d --- /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 5ee7bf7393e57c..f693ce701b3ab1 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/index.tsx b/docs/src/pages/index.tsx index a375efb8a8590d..a5dbc7aa98c6ba 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 b7c3c8af20b6ce..1f07e45122749d 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 36a8fed81df1e5..bf48b9c1410b70 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,16 @@ [ + { + "label": "v1.118.1", + "url": "https://v1.118.1.archive.immich.app" + }, + { + "label": "v1.118.0", + "url": "https://v1.118.0.archive.immich.app" + }, + { + "label": "v1.117.0", + "url": "https://v1.117.0.archive.immich.app" + }, { "label": "v1.116.2", "url": "https://v1.116.2.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 00000000000000..c2d593ea2a4df8 --- /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 00000000000000..d5d225201e669d --- /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 00000000000000..8c39fe0d40fea1 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 6123279f2d652b..00000000000000 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 62cc0367971798..00000000000000 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 00000000000000..51f92109d41719 --- /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 00000000000000..497fbdcf14902b --- /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 00000000000000..a6a7d0e1d6033e 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 00000000000000..0d88697f478a71 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 1ef26facbb6210..98f69bcd59b7ad 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 3516580bbbc04b..2a393af592b8cd 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 6169a4bfa1725d..40e800f054b984 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -21,6 +21,8 @@ services: - IMMICH_MACHINE_LEARNING_ENABLED=false - IMMICH_METRICS=true - IMMICH_ENV=testing + - IMMICH_PORT=2285 + - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true volumes: - ./test-assets:/test-assets extra_hosts: @@ -29,10 +31,10 @@ services: - redis - database ports: - - 2285:3001 + - 2285:2285 redis: - image: redis:6.2-alpine@sha256:2d1463258f2764328496376f5d965f20c6a67f66ea2b06dc42af351f75248792 + image: redis:6.2-alpine@sha256:2ba50e1ac3a0ea17b736ce9db2b0a9f6f8b85d4c27d5f5accc6a416d8f42c6d5 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 52a1cbd4afe8ab..2255bd1ef390f8 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.2", + "version": "1.118.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.2", + "version": "1.118.1", "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.9", + "@types/node": "^20.16.11", "@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.22", + "version": "2.2.25", "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.9", + "@types/node": "^20.16.11", "@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.116.2", + "version": "1.118.1", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.9", + "@types/node": "^20.16.11", "typescript": "^5.3.3" } }, @@ -1176,9 +1176,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "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==", + "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" ], @@ -1190,9 +1190,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "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==", + "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" ], @@ -1204,9 +1204,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "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==", + "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" ], @@ -1218,9 +1218,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "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==", + "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" ], @@ -1232,9 +1232,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "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==", + "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" ], @@ -1246,9 +1246,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "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==", + "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" ], @@ -1260,9 +1260,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "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==", + "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" ], @@ -1274,9 +1274,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "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==", + "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" ], @@ -1288,9 +1288,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "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==", + "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" ], @@ -1302,9 +1302,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "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==", + "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" ], @@ -1316,9 +1316,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "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==", + "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" ], @@ -1330,9 +1330,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "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==", + "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" ], @@ -1344,9 +1344,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "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==", + "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" ], @@ -1358,9 +1358,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "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==", + "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" ], @@ -1372,9 +1372,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "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==", + "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" ], @@ -1386,9 +1386,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "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==", + "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" ], @@ -1587,9 +1587,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.16.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", - "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", + "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": { @@ -1751,17 +1751,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz", - "integrity": "sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==", + "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.7.0", - "@typescript-eslint/type-utils": "8.7.0", - "@typescript-eslint/utils": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -1785,16 +1785,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.7.0.tgz", - "integrity": "sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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": { @@ -1814,14 +1814,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz", - "integrity": "sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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" @@ -1832,14 +1832,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz", - "integrity": "sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==", + "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.7.0", - "@typescript-eslint/utils": "8.7.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -1857,9 +1857,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.7.0.tgz", - "integrity": "sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==", + "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": { @@ -1871,14 +1871,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz", - "integrity": "sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -1926,16 +1926,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.7.0.tgz", - "integrity": "sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.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" @@ -1949,13 +1949,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz", - "integrity": "sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==", + "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.7.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -1967,9 +1967,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", - "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", + "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": { @@ -1990,8 +1990,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.1", - "vitest": "2.1.1" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2000,14 +2000,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", - "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", + "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.1.1", - "@vitest/utils": "2.1.1", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2016,9 +2016,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", - "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "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": { @@ -2030,7 +2030,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.1", + "@vitest/spy": "2.1.2", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2044,9 +2044,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", - "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", + "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": { @@ -2057,13 +2057,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", - "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", + "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.1.1", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -2071,13 +2071,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", - "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2086,9 +2086,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", - "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", + "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": { @@ -2099,13 +2099,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", - "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -3268,9 +3268,9 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.3.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.3.0.tgz", - "integrity": "sha512-2DOSOvj5c1gkbKtubAnlGglxdYp9h55n0GxjK2nypVivoaCdgP/le3MOZRKgEUNObfJHmYHj4u/NnYVneu/gUw==", + "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": { @@ -3580,16 +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, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -3642,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": { @@ -4370,14 +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, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "3.0.0", @@ -5624,13 +5611,13 @@ } }, "node_modules/rollup": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", - "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "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" @@ -5640,32 +5627,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@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", + "@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" } }, - "node_modules/rollup/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, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6406,9 +6386,9 @@ } }, "node_modules/vite": { - "version": "5.4.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz", - "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==", + "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": { @@ -6466,9 +6446,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", - "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", + "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": { @@ -6503,19 +6483,19 @@ } }, "node_modules/vitest": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", - "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", + "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": { - "@vitest/expect": "2.1.1", - "@vitest/mocker": "2.1.1", - "@vitest/pretty-format": "^2.1.1", - "@vitest/runner": "2.1.1", - "@vitest/snapshot": "2.1.1", - "@vitest/spy": "2.1.1", - "@vitest/utils": "2.1.1", + "@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.6", "magic-string": "^0.30.11", @@ -6526,7 +6506,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.1", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -6541,8 +6521,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.1", - "@vitest/ui": "2.1.1", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, diff --git a/e2e/package.json b/e2e/package.json index c107732ab35386..d9e9af21c036e6 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.2", + "version": "1.118.1", "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.9", + "@types/node": "^20.16.11", "@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/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index 20bd230159c284..fe0b4f2bd44bc0 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -347,6 +347,62 @@ describe('/libraries', () => { expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined(); }); + 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/folder, a`, `${testAssetDirInternal}/temp/folder, b`], + }); + + utils.createImageFile(`${testAssetDir}/temp/folder, a/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/folder, b/assetB.png`); + + 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 }); + + 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 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/folder{ a`, `${testAssetDirInternal}/temp/folder} b`], + }); + + utils.createImageFile(`${testAssetDir}/temp/folder{ a/assetA.png`); + utils.createImageFile(`${testAssetDir}/temp/folder} b/assetB.png`); + + 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 }); + + 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 reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -444,13 +500,13 @@ describe('/libraries', () => { }); it('should set an asset offline its file is not in any import path', async () => { + utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); + 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'); diff --git a/e2e/src/api/specs/oauth.e2e-spec.ts b/e2e/src/api/specs/oauth.e2e-spec.ts index a37a9528c9a7b3..42989a118f7fbb 100644 --- a/e2e/src/api/specs/oauth.e2e-spec.ts +++ b/e2e/src/api/specs/oauth.e2e-spec.ts @@ -17,6 +17,8 @@ const authServer = { external: 'http://127.0.0.1:3000', }; +const mobileOverrideRedirectUri = 'https://photos.immich.app/oauth/mobile-redirect'; + const redirect = async (url: string, cookies?: string[]) => { const { headers } = await request(url) .get('/') @@ -24,8 +26,8 @@ const redirect = async (url: string, cookies?: string[]) => { return { cookies: (headers['set-cookie'] as unknown as string[]) || [], location: headers.location }; }; -const loginWithOAuth = async (sub: OAuthUser | string) => { - const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: `${baseUrl}/auth/login` } }); +const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => { + const { url } = await startOAuth({ oAuthConfigDto: { redirectUri: redirectUri ?? `${baseUrl}/auth/login` } }); // login const response1 = await redirect(url.replace(authServer.internal, authServer.external)); @@ -255,4 +257,50 @@ describe(`/oauth`, () => { }); }); }); + + describe('mobile redirect override', () => { + beforeAll(async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + buttonText: 'Login with Immich', + storageLabelClaim: 'immich_username', + mobileOverrideEnabled: true, + mobileRedirectUri: mobileOverrideRedirectUri, + }); + }); + + it('should return the mobile redirect uri', async () => { + const { status, body } = await request(app) + .post('/oauth/authorize') + .send({ redirectUri: 'app.immich:///oauth-callback' }); + expect(status).toBe(201); + expect(body).toEqual({ url: expect.stringContaining(`${authServer.internal}/auth?`) }); + + const params = new URL(body.url).searchParams; + expect(params.get('client_id')).toBe('client-default'); + expect(params.get('response_type')).toBe('code'); + expect(params.get('redirect_uri')).toBe(mobileOverrideRedirectUri); + expect(params.get('state')).toBeDefined(); + }); + + it('should auto register the user by default', async () => { + const url = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback'); + expect(url).toEqual(expect.stringContaining(mobileOverrideRedirectUri)); + + // simulate redirecting back to mobile app + const redirectUri = url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback'); + + const { status, body } = await request(app).post('/oauth/callback').send({ url: redirectUri }); + expect(status).toBe(201); + expect(body).toMatchObject({ + accessToken: expect.any(String), + isAdmin: false, + name: 'OAuth User', + userEmail: 'oauth-mobile-override@immich.app', + userId: expect.any(String), + }); + }); + }); }); 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 1ef8d8602ad24a..00000000000000 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ /dev/null @@ -1,204 +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, - mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', - mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', - }); - }); - }); - - 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/cli/specs/upload.e2e-spec.ts b/e2e/src/cli/specs/upload.e2e-spec.ts index db2b6c534137e3..d700aa73b20914 100644 --- a/e2e/src/cli/specs/upload.e2e-spec.ts +++ b/e2e/src/cli/specs/upload.e2e-spec.ts @@ -1,9 +1,90 @@ import { LoginResponseDto, getAllAlbums, getAssetStatistics } from '@immich/sdk'; -import { readFileSync } from 'node:fs'; +import { cpSync, readFileSync } from 'node:fs'; import { mkdir, readdir, rm, symlink } from 'node:fs/promises'; -import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils'; +import { asKeyAuth, immichCli, specialCharStrings, testAssetDir, utils } from 'src/utils'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +interface Test { + test: string; + paths: string[]; + files: Record; +} + +const tests: Test[] = [ + { + test: 'should support globbing with *', + paths: [`/photos*`], + files: { + '/photos1/image1.jpg': true, + '/photos2/image2.jpg': true, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with an asterisk', + paths: [`/photos\*/image1.jpg`], + files: { + '/photos*/image1.jpg': true, + '/photos*/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a space', + paths: [`/my photos/image1.jpg`], + files: { + '/my photos/image1.jpg': true, + '/my photos/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a single quote', + paths: [`/photos\'/image1.jpg`], + files: { + "/photos'/image1.jpg": true, + "/photos'/image2.jpg": false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a double quote', + paths: [`/photos\"/image1.jpg`], + files: { + '/photos"/image1.jpg': true, + '/photos"/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a comma', + paths: [`/photos, eh/image1.jpg`], + files: { + '/photos, eh/image1.jpg': true, + '/photos, eh/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with an opening brace', + paths: [`/photos\{/image1.jpg`], + files: { + '/photos{/image1.jpg': true, + '/photos{/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, + { + test: 'should support paths with a closing brace', + paths: [`/photos\}/image1.jpg`], + files: { + '/photos}/image1.jpg': true, + '/photos}/image2.jpg': false, + '/images/image3.jpg': false, + }, + }, +]; + describe(`immich upload`, () => { let admin: LoginResponseDto; let key: string; @@ -32,6 +113,60 @@ describe(`immich upload`, () => { expect(assets.total).toBe(1); }); + describe(`should accept special cases`, () => { + for (const { test, paths, files } of tests) { + it(test, async () => { + const baseDir = `/tmp/upload/`; + + const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`); + testPaths.map((filePath) => utils.createImageFile(filePath)); + + const commandLine = paths.map((argument) => `${baseDir}/${argument}`); + + const expectedCount = Object.entries(files).filter((entry) => entry[1]).length; + + const { stderr, stdout, exitCode } = await immichCli(['upload', ...commandLine]); + expect(stderr).toBe(''); + expect(stdout.split('\n')).toEqual( + expect.arrayContaining([expect.stringContaining(`Successfully uploaded ${expectedCount} new asset`)]), + ); + expect(exitCode).toBe(0); + + const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); + expect(assets.total).toBe(expectedCount); + + testPaths.map((filePath) => utils.removeImageFile(filePath)); + }); + } + }); + + it.each(specialCharStrings)(`should upload a multiple files from paths containing %s`, async (testString) => { + // https://github.com/immich-app/immich/issues/12078 + + // NOTE: this test must contain more than one path since a related bug is only triggered with multiple paths + + const testPaths = [ + `${testAssetDir}/temp/dir1${testString}name/asset.jpg`, + `${testAssetDir}/temp/dir2${testString}name/asset.jpg`, + ]; + + cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, testPaths[0]); + cpSync(`${testAssetDir}/albums/nature/silver_fir.jpg`, testPaths[1]); + + const { stderr, stdout, exitCode } = await immichCli(['upload', ...testPaths]); + expect(stderr).toBe(''); + expect(stdout.split('\n')).toEqual( + expect.arrayContaining([expect.stringContaining('Successfully uploaded 2 new assets')]), + ); + expect(exitCode).toBe(0); + + utils.removeImageFile(testPaths[0]); + utils.removeImageFile(testPaths[1]); + + const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); + expect(assets.total).toBe(2); + }); + it('should skip a duplicate file', async () => { const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]); expect(first.stderr).toBe(''); diff --git a/e2e/src/setup/auth-server.ts b/e2e/src/setup/auth-server.ts index 3dd63fc4034261..cde50813ddede7 100644 --- a/e2e/src/setup/auth-server.ts +++ b/e2e/src/setup/auth-server.ts @@ -50,6 +50,7 @@ const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || wi const setup = async () => { const { privateKey, publicKey } = await generateKeyPair('RS256'); + const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect']; const port = 3000; const host = '0.0.0.0'; const oidc = new Provider(`http://${host}:${port}`, { @@ -86,14 +87,14 @@ const setup = async () => { { client_id: OAuthClient.DEFAULT, client_secret: OAuthClient.DEFAULT, - redirect_uris: ['http://127.0.0.1:2285/auth/login'], + redirect_uris: redirectUris, grant_types: ['authorization_code'], response_types: ['code'], }, { client_id: OAuthClient.RS256_TOKENS, client_secret: OAuthClient.RS256_TOKENS, - redirect_uris: ['http://127.0.0.1:2285/auth/login'], + redirect_uris: redirectUris, grant_types: ['authorization_code'], id_token_signed_response_alg: 'RS256', jwks: { keys: [await exportJWK(publicKey)] }, @@ -101,7 +102,7 @@ const setup = async () => { { client_id: OAuthClient.RS256_PROFILE, client_secret: OAuthClient.RS256_PROFILE, - redirect_uris: ['http://127.0.0.1:2285/auth/login'], + redirect_uris: redirectUris, grant_types: ['authorization_code'], userinfo_signed_response_alg: 'RS256', jwks: { keys: [await exportJWK(publicKey)] }, diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index e21b3bfd149342..3af44b50b83304 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -68,6 +68,7 @@ export const immichCli = (args: string[]) => executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise; export const immichAdmin = (args: string[]) => executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); +export const specialCharStrings = ["'", '"', ',', '{', '}', '*']; const executeCommand = (command: string, args: string[]) => { let _resolve: (value: CommandResponse) => void; @@ -373,8 +374,8 @@ export const utils = { }, createDirectory: (path: string) => { - if (!existsSync(dirname(path))) { - mkdirSync(dirname(path), { recursive: true }); + if (!existsSync(path)) { + mkdirSync(path, { recursive: true }); } }, @@ -391,7 +392,7 @@ export const utils = { return; } - rmSync(path); + rmSync(path, { recursive: true }); }, getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 3bfdf7d2e2cc67..155d78f4a34c39 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -104,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 af2d0aa4b91a9c..828dee15f09c13 100644 --- a/machine-learning/app/config.py +++ b/machine-learning/app/config.py @@ -6,7 +6,8 @@ from socket import socket from gunicorn.arbiter import Arbiter -from pydantic import BaseModel, BaseSettings +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict from rich.console import Console from rich.logging import RichHandler from uvicorn import Server @@ -14,11 +15,18 @@ class PreloadModelData(BaseModel): - clip: str | None - facial_recognition: str | None + clip: str | None = None + facial_recognition: str | None = None class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="MACHINE_LEARNING_", + case_sensitive=False, + env_nested_delimiter="__", + protected_namespaces=("settings_",), + ) + cache_folder: Path = Path("/cache") model_ttl: int = 300 model_ttl_poll_s: int = 10 @@ -34,19 +42,17 @@ class Settings(BaseSettings): ann_tuning_level: int = 2 preload: PreloadModelData | None = None - class Config: - env_prefix = "MACHINE_LEARNING_" - case_sensitive = False - env_nested_delimiter = "__" + @property + def device_id(self) -> str: + return os.environ.get("MACHINE_LEARNING_DEVICE_ID", "0") class LogSettings(BaseSettings): + model_config = SettingsConfigDict(case_sensitive=False) + immich_log_level: str = "info" no_color: bool = False - class Config: - case_sensitive = False - _clean_name = str.maketrans(":\\/", "___", ".") diff --git a/machine-learning/app/main.py b/machine-learning/app/main.py index 000119937e74a3..684001b875e412 100644 --- a/machine-learning/app/main.py +++ b/machine-learning/app/main.py @@ -12,7 +12,7 @@ import orjson from fastapi import Depends, FastAPI, File, Form, HTTPException -from fastapi.responses import ORJSONResponse +from fastapi.responses import ORJSONResponse, PlainTextResponse from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile from PIL.Image import Image from pydantic import ValidationError @@ -28,14 +28,12 @@ InferenceEntries, InferenceEntry, InferenceResponse, - MessageResponse, ModelFormat, ModelIdentity, ModelTask, ModelType, PipelineRequest, T, - TextResponse, ) MultiPartParser.max_file_size = 2**26 # spools to disk if payload is 64 MiB or larger @@ -127,14 +125,14 @@ def get_entries(entries: str = Form()) -> InferenceEntries: app = FastAPI(lifespan=lifespan) -@app.get("/", response_model=MessageResponse) -async def root() -> dict[str, str]: - return {"message": "Immich ML"} +@app.get("/") +async def root() -> ORJSONResponse: + return ORJSONResponse({"message": "Immich ML"}) -@app.get("/ping", response_model=TextResponse) -def ping() -> str: - return "pong" +@app.get("/ping") +def ping() -> PlainTextResponse: + return PlainTextResponse("pong") @app.post("/predict", dependencies=[Depends(update_state)]) diff --git a/machine-learning/app/schemas.py b/machine-learning/app/schemas.py index f051db12c3d4dc..a7ce2ee60da1e7 100644 --- a/machine-learning/app/schemas.py +++ b/machine-learning/app/schemas.py @@ -1,9 +1,9 @@ from enum import Enum -from typing import Any, Literal, Protocol, TypedDict, TypeGuard, TypeVar +from typing import Any, Literal, Protocol, TypeGuard, TypeVar import numpy as np import numpy.typing as npt -from pydantic import BaseModel +from typing_extensions import TypedDict class StrEnum(str, Enum): @@ -13,14 +13,6 @@ def __str__(self) -> str: return self.value -class TextResponse(BaseModel): - __root__: str - - -class MessageResponse(BaseModel): - message: str - - class BoundingBox(TypedDict): x1: int y1: int diff --git a/machine-learning/app/sessions/ort.py b/machine-learning/app/sessions/ort.py index 1a244b7c5750e5..00c7ad50a9ac7f 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 5f8e5b9e9c0f95..50ec188aa4ed67 100644 --- a/machine-learning/app/test_main.py +++ b/machine-learning/app/test_main.py @@ -210,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", @@ -796,11 +810,26 @@ async def test_falls_back_to_onnx_if_other_format_does_not_exist( mock_model.model_format = ModelFormat.ONNX +def test_root_endpoint(deployed_app: TestClient) -> None: + response = deployed_app.get("http://localhost:3003") + + body = response.json() + assert response.status_code == 200 + assert body == {"message": "Immich ML"} + + +def test_ping_endpoint(deployed_app: TestClient) -> None: + response = deployed_app.get("http://localhost:3003/ping") + + assert response.status_code == 200 + assert response.text == "pong" + + @pytest.mark.skipif( not settings.test_full, reason="More time-consuming since it deploys the app and loads models.", ) -class TestEndpoints: +class TestPredictionEndpoints: def test_clip_image_endpoint( self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient ) -> None: diff --git a/machine-learning/gunicorn_conf.py b/machine-learning/gunicorn_conf.py new file mode 100644 index 00000000000000..efec3a95aa4528 --- /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 1f6a378edaed00..feabfb0f9ef11f 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -40,6 +40,17 @@ develop = ["imgaug (>=0.4.0)", "pytest"] imgaug = ["imgaug (>=0.4.0)"] tests = ["pytest"] +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "anyio" version = "4.2.0" @@ -64,33 +75,33 @@ trio = ["trio (>=0.23)"] [[package]] name = "black" -version = "24.8.0" +version = "24.10.0" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, - {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, - {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, - {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, - {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, - {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, - {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, - {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, - {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, - {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, - {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, - {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, - {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, - {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, - {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, - {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, - {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, - {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, - {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, - {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, - {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, - {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, + {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, + {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, + {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, + {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, + {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, + {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, + {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, + {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, + {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, + {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, + {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, + {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, + {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, + {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, + {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, + {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, + {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, + {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, + {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, + {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, + {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, + {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, ] [package.dependencies] @@ -104,7 +115,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -1237,13 +1248,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "huggingface-hub" -version = "0.25.1" +version = "0.25.2" 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.25.1-py3-none-any.whl", hash = "sha256:a5158ded931b3188f54ea9028097312cb0acd50bffaaa2612014c3c526b44972"}, - {file = "huggingface_hub-0.25.1.tar.gz", hash = "sha256:9ff7cb327343211fbd06e2b149b8f362fd1e389454f3f14c6db75a4999ee20ff"}, + {file = "huggingface_hub-0.25.2-py3-none-any.whl", hash = "sha256:1897caf88ce7f97fe0110603d8f66ac264e3ba6accdf30cd66cc0fed5282ad25"}, + {file = "huggingface_hub-0.25.2.tar.gz", hash = "sha256:a1014ea111a5f40ccd23f7f7ba8ac46e20fa3b658ced1f86a00c75c06ec6423c"}, ] [package.dependencies] @@ -2037,22 +2048,22 @@ reference = "cuda12" [[package]] name = "onnxruntime-openvino" -version = "1.19.0" +version = "1.18.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime_openvino-1.19.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8c5658da819b26d9f35f95204e1bdfb74a100a7533e74edab3af6316c1e316e8"}, - {file = "onnxruntime_openvino-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb8de2a60cf78db6e201b0a489479995d166938e9c53b01ff342dc7f5f8251ff"}, - {file = "onnxruntime_openvino-1.19.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f3a0b954026286421b3a769c746c403e8f141f3887d1dd601beb7c4dbf77488a"}, - {file = "onnxruntime_openvino-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:12330922ecdb694ea28dbdcf08c172e47a5a84fee603040691341336ee3e42bc"}, - {file = "onnxruntime_openvino-1.19.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:be00502b1a46ba1891cbe49049033745f71c0b99df6d24b979f5b4084b9567d0"}, + {file = "onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331"}, + {file = "onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75"}, + {file = "onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba"}, + {file = "onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c"}, + {file = "onnxruntime_openvino-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:597eb18f3de7ead69b08a242d74c4573b28bbfba40ca2a1a40f75bf7a834808e"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.21.6" +numpy = ">=1.26.4" packaging = "*" protobuf = "*" sympy = "*" @@ -2374,62 +2385,147 @@ files = [ [[package]] name = "pydantic" -version = "1.10.18" -description = "Data validation and settings management using python type hints" +version = "2.9.2" +description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.5.2" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.8" files = [ - {file = "pydantic-1.10.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e405ffcc1254d76bb0e760db101ee8916b620893e6edfbfee563b3c6f7a67c02"}, - {file = "pydantic-1.10.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e306e280ebebc65040034bff1a0a81fd86b2f4f05daac0131f29541cafd80b80"}, - {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11d9d9b87b50338b1b7de4ebf34fd29fdb0d219dc07ade29effc74d3d2609c62"}, - {file = "pydantic-1.10.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b661ce52c7b5e5f600c0c3c5839e71918346af2ef20062705ae76b5c16914cab"}, - {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c20f682defc9ef81cd7eaa485879ab29a86a0ba58acf669a78ed868e72bb89e0"}, - {file = "pydantic-1.10.18-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c5ae6b7c8483b1e0bf59e5f1843e4fd8fd405e11df7de217ee65b98eb5462861"}, - {file = "pydantic-1.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:74fe19dda960b193b0eb82c1f4d2c8e5e26918d9cda858cbf3f41dd28549cb70"}, - {file = "pydantic-1.10.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72fa46abace0a7743cc697dbb830a41ee84c9db8456e8d77a46d79b537efd7ec"}, - {file = "pydantic-1.10.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef0fe7ad7cbdb5f372463d42e6ed4ca9c443a52ce544472d8842a0576d830da5"}, - {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a00e63104346145389b8e8f500bc6a241e729feaf0559b88b8aa513dd2065481"}, - {file = "pydantic-1.10.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae6fa2008e1443c46b7b3a5eb03800121868d5ab6bc7cda20b5df3e133cde8b3"}, - {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9f463abafdc92635da4b38807f5b9972276be7c8c5121989768549fceb8d2588"}, - {file = "pydantic-1.10.18-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3445426da503c7e40baccefb2b2989a0c5ce6b163679dd75f55493b460f05a8f"}, - {file = "pydantic-1.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:467a14ee2183bc9c902579bb2f04c3d3dac00eff52e252850509a562255b2a33"}, - {file = "pydantic-1.10.18-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:efbc8a7f9cb5fe26122acba1852d8dcd1e125e723727c59dcd244da7bdaa54f2"}, - {file = "pydantic-1.10.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:24a4a159d0f7a8e26bf6463b0d3d60871d6a52eac5bb6a07a7df85c806f4c048"}, - {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b74be007703547dc52e3c37344d130a7bfacca7df112a9e5ceeb840a9ce195c7"}, - {file = "pydantic-1.10.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcb20d4cb355195c75000a49bb4a31d75e4295200df620f454bbc6bdf60ca890"}, - {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:46f379b8cb8a3585e3f61bf9ae7d606c70d133943f339d38b76e041ec234953f"}, - {file = "pydantic-1.10.18-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cbfbca662ed3729204090c4d09ee4beeecc1a7ecba5a159a94b5a4eb24e3759a"}, - {file = "pydantic-1.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:c6d0a9f9eccaf7f438671a64acf654ef0d045466e63f9f68a579e2383b63f357"}, - {file = "pydantic-1.10.18-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3d5492dbf953d7d849751917e3b2433fb26010d977aa7a0765c37425a4026ff1"}, - {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe734914977eed33033b70bfc097e1baaffb589517863955430bf2e0846ac30f"}, - {file = "pydantic-1.10.18-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15fdbe568beaca9aacfccd5ceadfb5f1a235087a127e8af5e48df9d8a45ae85c"}, - {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c3e742f62198c9eb9201781fbebe64533a3bbf6a76a91b8d438d62b813079dbc"}, - {file = "pydantic-1.10.18-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:19a3bd00b9dafc2cd7250d94d5b578edf7a0bd7daf102617153ff9a8fa37871c"}, - {file = "pydantic-1.10.18-cp37-cp37m-win_amd64.whl", hash = "sha256:2ce3fcf75b2bae99aa31bd4968de0474ebe8c8258a0110903478bd83dfee4e3b"}, - {file = "pydantic-1.10.18-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:335a32d72c51a313b33fa3a9b0fe283503272ef6467910338e123f90925f0f03"}, - {file = "pydantic-1.10.18-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34a3613c7edb8c6fa578e58e9abe3c0f5e7430e0fc34a65a415a1683b9c32d9a"}, - {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ee4e6ca1d9616797fa2e9c0bfb8815912c7d67aca96f77428e316741082a1b"}, - {file = "pydantic-1.10.18-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:23e8ec1ce4e57b4f441fc91e3c12adba023fedd06868445a5b5f1d48f0ab3682"}, - {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:44ae8a3e35a54d2e8fa88ed65e1b08967a9ef8c320819a969bfa09ce5528fafe"}, - {file = "pydantic-1.10.18-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5389eb3b48a72da28c6e061a247ab224381435256eb541e175798483368fdd3"}, - {file = "pydantic-1.10.18-cp38-cp38-win_amd64.whl", hash = "sha256:069b9c9fc645474d5ea3653788b544a9e0ccd3dca3ad8c900c4c6eac844b4620"}, - {file = "pydantic-1.10.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80b982d42515632eb51f60fa1d217dfe0729f008e81a82d1544cc392e0a50ddf"}, - {file = "pydantic-1.10.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aad8771ec8dbf9139b01b56f66386537c6fe4e76c8f7a47c10261b69ad25c2c9"}, - {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941a2eb0a1509bd7f31e355912eb33b698eb0051730b2eaf9e70e2e1589cae1d"}, - {file = "pydantic-1.10.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65f7361a09b07915a98efd17fdec23103307a54db2000bb92095457ca758d485"}, - {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6951f3f47cb5ca4da536ab161ac0163cab31417d20c54c6de5ddcab8bc813c3f"}, - {file = "pydantic-1.10.18-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a4c5eec138a9b52c67f664c7d51d4c7234c5ad65dd8aacd919fb47445a62c86"}, - {file = "pydantic-1.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:49e26c51ca854286bffc22b69787a8d4063a62bf7d83dc21d44d2ff426108518"}, - {file = "pydantic-1.10.18-py3-none-any.whl", hash = "sha256:06a189b81ffc52746ec9c8c007f16e5167c8b0a696e1a726369327e3db7b2a82"}, - {file = "pydantic-1.10.18.tar.gz", hash = "sha256:baebdff1907d1d96a139c25136a9bb7d17e118f133a76a2ef3b845e831e3403a"}, + {file = "pydantic_settings-2.5.2-py3-none-any.whl", hash = "sha256:2c912e55fd5794a59bf8c832b9de832dcfdf4778d79ff79b708744eed499a907"}, + {file = "pydantic_settings-2.5.2.tar.gz", hash = "sha256:f90b139682bee4d2065273d5185d71d37ea46cfe57e1b5ae184fc6a0b2484ca0"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] [[package]] name = "pygments" @@ -2576,13 +2672,13 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.10" +version = "0.0.12" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.10-py3-none-any.whl", hash = "sha256:2b06ad9e8d50c7a8db80e3b56dab590137b323410605af2be20d62a5f1ba1dc8"}, - {file = "python_multipart-0.0.10.tar.gz", hash = "sha256:46eb3c6ce6fdda5fb1a03c7e11d490e407c6930a2703fe7aef4da71c374688fa"}, + {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]] @@ -2813,47 +2909,48 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rich" -version = "13.8.1" +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.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"}, - {file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"}, + {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.8" +version = "0.6.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {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"}, + {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, + {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, + {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, + {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, + {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, + {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, + {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, + {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, + {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, ] [[package]] @@ -3091,111 +3188,111 @@ all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib" [[package]] name = "tokenizers" -version = "0.20.0" +version = "0.20.1" description = "" optional = false python-versions = ">=3.7" files = [ - {file = "tokenizers-0.20.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:6cff5c5e37c41bc5faa519d6f3df0679e4b37da54ea1f42121719c5e2b4905c0"}, - {file = "tokenizers-0.20.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:62a56bf75c27443432456f4ca5ca055befa95e25be8a28141cc495cac8ae4d6d"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68cc7de6a63f09c4a86909c2597b995aa66e19df852a23aea894929c74369929"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:053c37ecee482cc958fdee53af3c6534286a86f5d35aac476f7c246830e53ae5"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d7074aaabc151a6363fa03db5493fc95b423b2a1874456783989e96d541c7b6"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a11435780f2acd89e8fefe5e81cecf01776f6edb9b3ac95bcb76baee76b30b90"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a81cd2712973b007d84268d45fc3f6f90a79c31dfe7f1925e6732f8d2959987"}, - {file = "tokenizers-0.20.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7dfd796ab9d909f76fb93080e1c7c8309f196ecb316eb130718cd5e34231c69"}, - {file = "tokenizers-0.20.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8029ad2aa8cb00605c9374566034c1cc1b15130713e0eb5afcef6cface8255c9"}, - {file = "tokenizers-0.20.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ca4d54260ebe97d59dfa9a30baa20d0c4dd9137d99a8801700055c561145c24e"}, - {file = "tokenizers-0.20.0-cp310-none-win32.whl", hash = "sha256:95ee16b57cec11b86a7940174ec5197d506439b0f415ab3859f254b1dffe9df0"}, - {file = "tokenizers-0.20.0-cp310-none-win_amd64.whl", hash = "sha256:0a61a11e93eeadbf02aea082ffc75241c4198e0608bbbac4f65a9026851dcf37"}, - {file = "tokenizers-0.20.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6636b798b3c4d6c9b1af1a918bd07c867808e5a21c64324e95318a237e6366c3"}, - {file = "tokenizers-0.20.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ec603e42eaf499ffd58b9258162add948717cf21372458132f14e13a6bc7172"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cce124264903a8ea6f8f48e1cc7669e5ef638c18bd4ab0a88769d5f92debdf7f"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07bbeba0231cf8de07aa6b9e33e9779ff103d47042eeeb859a8c432e3292fb98"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:06c0ca8397b35d38b83a44a9c6929790c1692957d88541df061cb34d82ebbf08"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca6557ac3b83d912dfbb1f70ab56bd4b0594043916688e906ede09f42e192401"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a5ad94c9e80ac6098328bee2e3264dbced4c6faa34429994d473f795ec58ef4"}, - {file = "tokenizers-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b5c7f906ee6bec30a9dc20268a8b80f3b9584de1c9f051671cb057dc6ce28f6"}, - {file = "tokenizers-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:31e087e9ee1b8f075b002bfee257e858dc695f955b43903e1bb4aa9f170e37fe"}, - {file = "tokenizers-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c3124fb6f3346cb3d8d775375d3b429bf4dcfc24f739822702009d20a4297990"}, - {file = "tokenizers-0.20.0-cp311-none-win32.whl", hash = "sha256:a4bb8b40ba9eefa621fdcabf04a74aa6038ae3be0c614c6458bd91a4697a452f"}, - {file = "tokenizers-0.20.0-cp311-none-win_amd64.whl", hash = "sha256:2b709d371f1fe60a28ef0c5c67815952d455ca7f34dbe7197eaaed3cc54b658e"}, - {file = "tokenizers-0.20.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:15c81a17d0d66f4987c6ca16f4bea7ec253b8c7ed1bb00fdc5d038b1bb56e714"}, - {file = "tokenizers-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a531cdf1fb6dc41c984c785a3b299cb0586de0b35683842a3afbb1e5207f910"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06caabeb4587f8404e0cd9d40f458e9cba3e815c8155a38e579a74ff3e2a4301"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8768f964f23f5b9f50546c0369c75ab3262de926983888bbe8b98be05392a79c"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:626403860152c816f97b649fd279bd622c3d417678c93b4b1a8909b6380b69a8"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c1b88fa9e5ff062326f4bf82681da5a96fca7104d921a6bd7b1e6fcf224af26"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d7e559436a07dc547f22ce1101f26d8b2fad387e28ec8e7e1e3b11695d681d8"}, - {file = "tokenizers-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e48afb75e50449848964e4a67b0da01261dd3aa8df8daecf10db8fd7f5b076eb"}, - {file = "tokenizers-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:baf5d0e1ff44710a95eefc196dd87666ffc609fd447c5e5b68272a7c3d342a1d"}, - {file = "tokenizers-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e5e56df0e8ed23ba60ae3848c3f069a0710c4b197218fe4f89e27eba38510768"}, - {file = "tokenizers-0.20.0-cp312-none-win32.whl", hash = "sha256:ec53e5ecc142a82432f9c6c677dbbe5a2bfee92b8abf409a9ecb0d425ee0ce75"}, - {file = "tokenizers-0.20.0-cp312-none-win_amd64.whl", hash = "sha256:f18661ece72e39c0dfaa174d6223248a15b457dbd4b0fc07809b8e6d3ca1a234"}, - {file = "tokenizers-0.20.0-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:f7065b1084d8d1a03dc89d9aad69bcbc8415d4bc123c367063eb32958cd85054"}, - {file = "tokenizers-0.20.0-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:e5d4069e4714e3f7ba0a4d3d44f9d84a432cd4e4aa85c3d7dd1f51440f12e4a1"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:799b808529e54b7e1a36350bda2aeb470e8390e484d3e98c10395cee61d4e3c6"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f9baa027cc8a281ad5f7725a93c204d7a46986f88edbe8ef7357f40a23fb9c7"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:010ec7f3f7a96adc4c2a34a3ada41fa14b4b936b5628b4ff7b33791258646c6b"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98d88f06155335b14fd78e32ee28ca5b2eb30fced4614e06eb14ae5f7fba24ed"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e13eb000ef540c2280758d1b9cfa5fe424b0424ae4458f440e6340a4f18b2638"}, - {file = "tokenizers-0.20.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fab3cf066ff426f7e6d70435dc28a9ff01b2747be83810e397cba106f39430b0"}, - {file = "tokenizers-0.20.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:39fa3761b30a89368f322e5daf4130dce8495b79ad831f370449cdacfb0c0d37"}, - {file = "tokenizers-0.20.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c8da0fba4d179ddf2607821575998df3c294aa59aa8df5a6646dc64bc7352bce"}, - {file = "tokenizers-0.20.0-cp37-none-win32.whl", hash = "sha256:fada996d6da8cf213f6e3c91c12297ad4f6cdf7a85c2fadcd05ec32fa6846fcd"}, - {file = "tokenizers-0.20.0-cp37-none-win_amd64.whl", hash = "sha256:7d29aad702279e0760c265fcae832e89349078e3418dd329732d4503259fd6bd"}, - {file = "tokenizers-0.20.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:099c68207f3ef0227ecb6f80ab98ea74de559f7b124adc7b17778af0250ee90a"}, - {file = "tokenizers-0.20.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:68012d8a8cddb2eab3880870d7e2086cb359c7f7a2b03f5795044f5abff4e850"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9253bdd209c6aee168deca7d0e780581bf303e0058f268f9bb06859379de19b6"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f868600ddbcb0545905ed075eb7218a0756bf6c09dae7528ea2f8436ebd2c93"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9643d9c8c5f99b6aba43fd10034f77cc6c22c31f496d2f0ee183047d948fa0"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c375c6a889aeab44734028bc65cc070acf93ccb0f9368be42b67a98e1063d3f6"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e359f852328e254f070bbd09a19a568421d23388f04aad9f2fb7da7704c7228d"}, - {file = "tokenizers-0.20.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d98b01a309d4387f3b1c1dd68a8b8136af50376cf146c1b7e8d8ead217a5be4b"}, - {file = "tokenizers-0.20.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:459f7537119554c2899067dec1ac74a00d02beef6558f4ee2e99513bf6d568af"}, - {file = "tokenizers-0.20.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:392b87ec89452628c045c9f2a88bc2a827f4c79e7d84bc3b72752b74c2581f70"}, - {file = "tokenizers-0.20.0-cp38-none-win32.whl", hash = "sha256:55a393f893d2ed4dd95a1553c2e42d4d4086878266f437b03590d3f81984c4fe"}, - {file = "tokenizers-0.20.0-cp38-none-win_amd64.whl", hash = "sha256:30ffe33c5c2f2aab8e9a3340d0110dd9f7ace7eec7362e20a697802306bd8068"}, - {file = "tokenizers-0.20.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:aa2d4a6fed2a7e3f860c7fc9d48764bb30f2649d83915d66150d6340e06742b8"}, - {file = "tokenizers-0.20.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b5ef0f814084a897e9071fc4a868595f018c5c92889197bdc4bf19018769b148"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc1e1b791e8c3bf4c4f265f180dadaff1c957bf27129e16fdd5e5d43c2d3762c"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b69e55e481459c07885263743a0d3c18d52db19bae8226a19bcca4aaa213fff"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806b4d82e27a2512bc23057b2986bc8b85824914286975b84d8105ff40d03d9"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9859e9ef13adf5a473ccab39d31bff9c550606ae3c784bf772b40f615742a24f"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef703efedf4c20488a8eb17637b55973745b27997ff87bad88ed499b397d1144"}, - {file = "tokenizers-0.20.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eec0061bab94b1841ab87d10831fdf1b48ebaed60e6d66d66dbe1d873f92bf5"}, - {file = "tokenizers-0.20.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:980f3d0d7e73f845b69087f29a63c11c7eb924c4ad6b358da60f3db4cf24bdb4"}, - {file = "tokenizers-0.20.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7c157550a2f3851b29d7fdc9dc059fcf81ff0c0fc49a1e5173a89d533ed043fa"}, - {file = "tokenizers-0.20.0-cp39-none-win32.whl", hash = "sha256:8a3d2f4d08608ec4f9895ec25b4b36a97f05812543190a5f2c3cd19e8f041e5a"}, - {file = "tokenizers-0.20.0-cp39-none-win_amd64.whl", hash = "sha256:d90188d12afd0c75e537f9a1d92f9c7375650188ee4f48fdc76f9e38afbd2251"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d68e15f1815357b059ec266062340c343ea7f98f7f330602df81ffa3474b6122"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:23f9ecec637b9bc80da5f703808d29ed5329e56b5aa8d791d1088014f48afadc"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f830b318ee599e3d0665b3e325f85bc75ee2d2ca6285f52e439dc22b64691580"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3dc750def789cb1de1b5a37657919545e1d9ffa667658b3fa9cb7862407a1b8"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e26e6c755ae884c2ea6135cd215bdd0fccafe4ee62405014b8c3cd19954e3ab9"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a1158c7174f427182e08baa2a8ded2940f2b4a3e94969a85cc9cfd16004cbcea"}, - {file = "tokenizers-0.20.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:6324826287a3fc198898d3dcf758fe4a8479e42d6039f4c59e2cedd3cf92f64e"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7d8653149405bb0c16feaf9cfee327fdb6aaef9dc2998349fec686f35e81c4e2"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a2dc1e402a155e97309287ca085c80eb1b7fab8ae91527d3b729181639fa51"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bef67b20aa6e5f7868c42c7c5eae4d24f856274a464ae62e47a0f2cccec3da"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da06e397182ff53789c506c7833220c192952c57e1581a53f503d8d953e2d67e"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:302f7e11a14814028b7fc88c45a41f1bbe9b5b35fd76d6869558d1d1809baa43"}, - {file = "tokenizers-0.20.0-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:055ec46e807b875589dfbe3d9259f9a6ee43394fb553b03b3d1e9541662dbf25"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3144b8acebfa6ae062e8f45f7ed52e4b50fb6c62f93afc8871b525ab9fdcab3"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b52aa3fd14b2a07588c00a19f66511cff5cca8f7266ca3edcdd17f3512ad159f"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b8cf52779ffc5d4d63a0170fbeb512372bad0dd014ce92bbb9149756c831124"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:983a45dd11a876124378dae71d6d9761822199b68a4c73f32873d8cdaf326a5b"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df6b819c9a19831ebec581e71a7686a54ab45d90faf3842269a10c11d746de0c"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e738cfd80795fcafcef89c5731c84b05638a4ab3f412f97d5ed7765466576eb1"}, - {file = "tokenizers-0.20.0-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:c8842c7be2fadb9c9edcee233b1b7fe7ade406c99b0973f07439985c1c1d0683"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e47a82355511c373a4a430c4909dc1e518e00031207b1fec536c49127388886b"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:9afbf359004551179a5db19424180c81276682773cff2c5d002f6eaaffe17230"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a07eaa8799a92e6af6f472c21a75bf71575de2af3c0284120b7a09297c0de2f3"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0994b2e5fc53a301071806bc4303e4bc3bdc3f490e92a21338146a36746b0872"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b6466e0355b603d10e3cc3d282d350b646341b601e50969464a54939f9848d0"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:1e86594c2a433cb1ea09cfbe596454448c566e57ee8905bd557e489d93e89986"}, - {file = "tokenizers-0.20.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3e14cdef1efa96ecead6ea64a891828432c3ebba128bdc0596e3059fea104ef3"}, - {file = "tokenizers-0.20.0.tar.gz", hash = "sha256:39d7acc43f564c274085cafcd1dae9d36f332456de1a31970296a6b8da4eac8d"}, + {file = "tokenizers-0.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:439261da7c0a5c88bda97acb284d49fbdaf67e9d3b623c0bfd107512d22787a9"}, + {file = "tokenizers-0.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03dae629d99068b1ea5416d50de0fea13008f04129cc79af77a2a6392792d93c"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b61f561f329ffe4b28367798b89d60c4abf3f815d37413b6352bc6412a359867"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec870fce1ee5248a10be69f7a8408a234d6f2109f8ea827b4f7ecdbf08c9fd15"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d388d1ea8b7447da784e32e3b86a75cce55887e3b22b31c19d0b186b1c677800"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:299c85c1d21135bc01542237979bf25c32efa0d66595dd0069ae259b97fb2dbe"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96f6c14c9752bb82145636b614d5a78e9cde95edfbe0a85dad0dd5ddd6ec95c"}, + {file = "tokenizers-0.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc9e95ad49c932b80abfbfeaf63b155761e695ad9f8a58c52a47d962d76e310f"}, + {file = "tokenizers-0.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f22dee205329a636148c325921c73cf3e412e87d31f4d9c3153b302a0200057b"}, + {file = "tokenizers-0.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2ffd9a8895575ac636d44500c66dffaef133823b6b25067604fa73bbc5ec09d"}, + {file = "tokenizers-0.20.1-cp310-none-win32.whl", hash = "sha256:2847843c53f445e0f19ea842a4e48b89dd0db4e62ba6e1e47a2749d6ec11f50d"}, + {file = "tokenizers-0.20.1-cp310-none-win_amd64.whl", hash = "sha256:f9aa93eacd865f2798b9e62f7ce4533cfff4f5fbd50c02926a78e81c74e432cd"}, + {file = "tokenizers-0.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4a717dcb08f2dabbf27ae4b6b20cbbb2ad7ed78ce05a829fae100ff4b3c7ff15"}, + {file = "tokenizers-0.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3f84dad1ff1863c648d80628b1b55353d16303431283e4efbb6ab1af56a75832"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:929c8f3afa16a5130a81ab5079c589226273ec618949cce79b46d96e59a84f61"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10766473954397e2d370f215ebed1cc46dcf6fd3906a2a116aa1d6219bfedc3"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9300fac73ddc7e4b0330acbdda4efaabf74929a4a61e119a32a181f534a11b47"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ecaf7b0e39caeb1aa6dd6e0975c405716c82c1312b55ac4f716ef563a906969"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5170be9ec942f3d1d317817ced8d749b3e1202670865e4fd465e35d8c259de83"}, + {file = "tokenizers-0.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f1ae08fa9aea5891cbd69df29913e11d3841798e0bfb1ff78b78e4e7ea0a4"}, + {file = "tokenizers-0.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ee86d4095d3542d73579e953c2e5e07d9321af2ffea6ecc097d16d538a2dea16"}, + {file = "tokenizers-0.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:86dcd08da163912e17b27bbaba5efdc71b4fbffb841530fdb74c5707f3c49216"}, + {file = "tokenizers-0.20.1-cp311-none-win32.whl", hash = "sha256:9af2dc4ee97d037bc6b05fa4429ddc87532c706316c5e11ce2f0596dfcfa77af"}, + {file = "tokenizers-0.20.1-cp311-none-win_amd64.whl", hash = "sha256:899152a78b095559c287b4c6d0099469573bb2055347bb8154db106651296f39"}, + {file = "tokenizers-0.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:407ab666b38e02228fa785e81f7cf79ef929f104bcccf68a64525a54a93ceac9"}, + {file = "tokenizers-0.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f13a2d16032ebc8bd812eb8099b035ac65887d8f0c207261472803b9633cf3e"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e98eee4dca22849fbb56a80acaa899eec5b72055d79637dd6aa15d5e4b8628c9"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47c1bcdd61e61136087459cb9e0b069ff23b5568b008265e5cbc927eae3387ce"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128c1110e950534426e2274837fc06b118ab5f2fa61c3436e60e0aada0ccfd67"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2e2d47a819d2954f2c1cd0ad51bb58ffac6f53a872d5d82d65d79bf76b9896d"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd67a0e3503a9a7cf8bc5a4a49cdde5fa5bada09a51e4c7e1c73900297539bd"}, + {file = "tokenizers-0.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:689b93d2e26d04da337ac407acec8b5d081d8d135e3e5066a88edd5bdb5aff89"}, + {file = "tokenizers-0.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0c6a796ddcd9a19ad13cf146997cd5895a421fe6aec8fd970d69f9117bddb45c"}, + {file = "tokenizers-0.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3ea919687aa7001a8ff1ba36ac64f165c4e89035f57998fa6cedcfd877be619d"}, + {file = "tokenizers-0.20.1-cp312-none-win32.whl", hash = "sha256:6d3ac5c1f48358ffe20086bf065e843c0d0a9fce0d7f0f45d5f2f9fba3609ca5"}, + {file = "tokenizers-0.20.1-cp312-none-win_amd64.whl", hash = "sha256:b0874481aea54a178f2bccc45aa2d0c99cd3f79143a0948af6a9a21dcc49173b"}, + {file = "tokenizers-0.20.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:96af92e833bd44760fb17f23f402e07a66339c1dcbe17d79a9b55bb0cc4f038e"}, + {file = "tokenizers-0.20.1-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:65f34e5b731a262dfa562820818533c38ce32a45864437f3d9c82f26c139ca7f"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17f98fccb5c12ab1ce1f471731a9cd86df5d4bd2cf2880c5a66b229802d96145"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8c0fc3542cf9370bf92c932eb71bdeb33d2d4aeeb4126d9fd567b60bd04cb30"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b39356df4575d37f9b187bb623aab5abb7b62c8cb702867a1768002f814800c"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfdad27b0e50544f6b838895a373db6114b85112ba5c0cefadffa78d6daae563"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:094663dd0e85ee2e573126918747bdb40044a848fde388efb5b09d57bc74c680"}, + {file = "tokenizers-0.20.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e4cf033a2aa207d7ac790e91adca598b679999710a632c4a494aab0fc3a1b2"}, + {file = "tokenizers-0.20.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9310951c92c9fb91660de0c19a923c432f110dbfad1a2d429fbc44fa956bf64f"}, + {file = "tokenizers-0.20.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:05e41e302c315bd2ed86c02e917bf03a6cf7d2f652c9cee1a0eb0d0f1ca0d32c"}, + {file = "tokenizers-0.20.1-cp37-none-win32.whl", hash = "sha256:212231ab7dfcdc879baf4892ca87c726259fa7c887e1688e3f3cead384d8c305"}, + {file = "tokenizers-0.20.1-cp37-none-win_amd64.whl", hash = "sha256:896195eb9dfdc85c8c052e29947169c1fcbe75a254c4b5792cdbd451587bce85"}, + {file = "tokenizers-0.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:741fb22788482d09d68e73ece1495cfc6d9b29a06c37b3df90564a9cfa688e6d"}, + {file = "tokenizers-0.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10be14ebd8082086a342d969e17fc2d6edc856c59dbdbddd25f158fa40eaf043"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:514cf279b22fa1ae0bc08e143458c74ad3b56cd078b319464959685a35c53d5e"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a647c5b7cb896d6430cf3e01b4e9a2d77f719c84cefcef825d404830c2071da2"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cdf379219e1e1dd432091058dab325a2e6235ebb23e0aec8d0508567c90cd01"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ba72260449e16c4c2f6f3252823b059fbf2d31b32617e582003f2b18b415c39"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:910b96ed87316e4277b23c7bcaf667ce849c7cc379a453fa179e7e09290eeb25"}, + {file = "tokenizers-0.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53975a6694428a0586534cc1354b2408d4e010a3103117f617cbb550299797c"}, + {file = "tokenizers-0.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:07c4b7be58da142b0730cc4e5fd66bb7bf6f57f4986ddda73833cd39efef8a01"}, + {file = "tokenizers-0.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b605c540753e62199bf15cf69c333e934077ef2350262af2ccada46026f83d1c"}, + {file = "tokenizers-0.20.1-cp38-none-win32.whl", hash = "sha256:88b3bc76ab4db1ab95ead623d49c95205411e26302cf9f74203e762ac7e85685"}, + {file = "tokenizers-0.20.1-cp38-none-win_amd64.whl", hash = "sha256:d412a74cf5b3f68a90c615611a5aa4478bb303d1c65961d22db45001df68afcb"}, + {file = "tokenizers-0.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a25dcb2f41a0a6aac31999e6c96a75e9152fa0127af8ece46c2f784f23b8197a"}, + {file = "tokenizers-0.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a12c3cebb8c92e9c35a23ab10d3852aee522f385c28d0b4fe48c0b7527d59762"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02e18da58cf115b7c40de973609c35bde95856012ba42a41ee919c77935af251"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f326a1ac51ae909b9760e34671c26cd0dfe15662f447302a9d5bb2d872bab8ab"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b4872647ea6f25224e2833b044b0b19084e39400e8ead3cfe751238b0802140"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce6238a3311bb8e4c15b12600927d35c267b92a52c881ef5717a900ca14793f7"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57b7a8880b208866508b06ce365dc631e7a2472a3faa24daa430d046fb56c885"}, + {file = "tokenizers-0.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a908c69c2897a68f412aa05ba38bfa87a02980df70f5a72fa8490479308b1f2d"}, + {file = "tokenizers-0.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:da1001aa46f4490099c82e2facc4fbc06a6a32bf7de3918ba798010954b775e0"}, + {file = "tokenizers-0.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42c097390e2f0ed0a5c5d569e6669dd4e9fff7b31c6a5ce6e9c66a61687197de"}, + {file = "tokenizers-0.20.1-cp39-none-win32.whl", hash = "sha256:3d4d218573a3d8b121a1f8c801029d70444ffb6d8f129d4cca1c7b672ee4a24c"}, + {file = "tokenizers-0.20.1-cp39-none-win_amd64.whl", hash = "sha256:37d1e6f616c84fceefa7c6484a01df05caf1e207669121c66213cb5b2911d653"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48689da7a395df41114f516208d6550e3e905e1239cc5ad386686d9358e9cef0"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:712f90ea33f9bd2586b4a90d697c26d56d0a22fd3c91104c5858c4b5b6489a79"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:359eceb6a620c965988fc559cebc0a98db26713758ec4df43fb76d41486a8ed5"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d3caf244ce89d24c87545aafc3448be15870096e796c703a0d68547187192e1"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03b03cf8b9a32254b1bf8a305fb95c6daf1baae0c1f93b27f2b08c9759f41dee"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:218e5a3561561ea0f0ef1559c6d95b825308dbec23fb55b70b92589e7ff2e1e8"}, + {file = "tokenizers-0.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f40df5e0294a95131cc5f0e0eb91fe86d88837abfbee46b9b3610b09860195a7"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-macosx_10_12_x86_64.whl", hash = "sha256:08aaa0d72bb65058e8c4b0455f61b840b156c557e2aca57627056624c3a93976"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:998700177b45f70afeb206ad22c08d9e5f3a80639dae1032bf41e8cbc4dada4b"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62f7fbd3c2c38b179556d879edae442b45f68312019c3a6013e56c3947a4e648"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31e87fca4f6bbf5cc67481b562147fe932f73d5602734de7dd18a8f2eee9c6dd"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:956f21d359ae29dd51ca5726d2c9a44ffafa041c623f5aa33749da87cfa809b9"}, + {file = "tokenizers-0.20.1-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1fbbaf17a393c78d8aedb6a334097c91cb4119a9ced4764ab8cfdc8d254dc9f9"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ebe63e31f9c1a970c53866d814e35ec2ec26fda03097c486f82f3891cee60830"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:81970b80b8ac126910295f8aab2d7ef962009ea39e0d86d304769493f69aaa1e"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130e35e76f9337ed6c31be386e75d4925ea807055acf18ca1a9b0eec03d8fe23"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd28a8614f5c82a54ab2463554e84ad79526c5184cf4573bbac2efbbbcead457"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9041ee665d0fa7f5c4ccf0f81f5e6b7087f797f85b143c094126fc2611fec9d0"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:62eb9daea2a2c06bcd8113a5824af8ef8ee7405d3a71123ba4d52c79bb3d9f1a"}, + {file = "tokenizers-0.20.1-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f861889707b54a9ab1204030b65fd6c22bdd4a95205deec7994dc22a8baa2ea4"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:89d5c337d74ea6e5e7dc8af124cf177be843bbb9ca6e58c01f75ea103c12c8a9"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:0b7f515c83397e73292accdbbbedc62264e070bae9682f06061e2ddce67cacaf"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e0305fc1ec6b1e5052d30d9c1d5c807081a7bd0cae46a33d03117082e91908c"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc611e6ac0fa00a41de19c3bf6391a05ea201d2d22b757d63f5491ec0e67faa"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c5ffe0d7f7bfcfa3b2585776ecf11da2e01c317027c8573c78ebcb8985279e23"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e7edb8ec12c100d5458d15b1e47c0eb30ad606a05641f19af7563bc3d1608c14"}, + {file = "tokenizers-0.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:de291633fb9303555793cc544d4a86e858da529b7d0b752bcaf721ae1d74b2c9"}, + {file = "tokenizers-0.20.1.tar.gz", hash = "sha256:84edcc7cdeeee45ceedb65d518fffb77aec69311c9c8e30f77ad84da3025f002"}, ] [package.dependencies] @@ -3266,13 +3363,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.30.6" +version = "0.31.1" 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.1-py3-none-any.whl", hash = "sha256:adc42d9cac80cf3e51af97c1851648066841e7cfb6993a4ca8de29ac1548ed41"}, + {file = "uvicorn-0.31.1.tar.gz", hash = "sha256:f5167919867b161b7bcaf32646c6a94cdbd4c3aa2eb5c17d36bb9aa5cfd8c493"}, ] [package.dependencies] @@ -3604,4 +3701,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 = "f4594d26ee661fb239c7b5750a4c79e5e049480182928af816ccf5e34e8b641f" diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 840aa93c064535..3376558c421e63 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.2" +version = "1.118.1" description = "" authors = ["Hau Tran "] readme = "README.md" @@ -13,7 +13,8 @@ opencv-python-headless = ">=4.7.0.72,<5.0" pillow = ">=9.5.0,<11.0" fastapi-slim = ">=0.95.2,<1.0" uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"} -pydantic = "^1.10.8" +pydantic = "^2.0.0" +pydantic-settings = "^2.5.2" aiocache = ">=0.12.1,<1.0" rich = ">=13.4.2" ftfy = ">=6.1.1" @@ -51,7 +52,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 c3fda523df8329..552cca1f5e5f57 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/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index d1f09a011f4fee..4407c7c5967422 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" => 161, - "android.injected.version.name" => "1.116.2", + "android.injected.version.code" => 163, + "android.injected.version.name" => "1.118.1", } ) 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/ar-JO.json b/mobile/assets/i18n/ar-JO.json index fdc54da2b777f1..cbf05ca49cfcff 100644 --- a/mobile/assets/i18n/ar-JO.json +++ b/mobile/assets/i18n/ar-JO.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "تحديث", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "تمت الاضافة{album}", "add_to_album_bottom_sheet_already_exists": "موجودة مسبقا {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "استكشاف الأخطاء وإصلاحها", "album_info_card_backup_album_excluded": "مستبعد", "album_info_card_backup_album_included": "متضمنة", + "albums": "Albums", "album_thumbnail_card_item": "عنصر واحد", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · . مشترك", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "إزالة من الألبوم", "album_viewer_appbar_share_to": "حصة ل", "album_viewer_page_share_add_users": "اضافة مستخدمين", + "all": "All", "all_people_page_title": "الناس", "all_videos_page_title": "أشرطة فيديو", "app_bar_signout_dialog_content": "هل أنت متأكد أنك تريد الخروج", "app_bar_signout_dialog_ok": "نعم", "app_bar_signout_dialog_title": "خروج", + "archived": "Archived", "archive_page_no_archived_assets": "لم يتم العثور على الأصول المؤرشفة", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "لا يمكن حذف الأصول ذات للقراءة فقط، وسوف يتم التخطي", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "غير محفوظ في الارشيف", "control_bottom_app_bar_unfavorite": "غير مفضل", "control_bottom_app_bar_upload": "رفع وتحميل", + "create_album": "Create album", "create_album_page_untitled": "بدون اسم", + "create_new": "CREATE NEW", "create_shared_album_page_create": "انشاء", "create_shared_album_page_share": "يشارك", "create_shared_album_page_share_add_assets": "إضافة الأصول", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "حذف الرابط المشترك", "description_input_hint_text": "اضف وصفا...", "description_input_submit_error": "خطأ تحديث الوصف ، تحقق من السجل لمزيد من التفاصيل", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "التاريخ و الوقت", "edit_date_time_dialog_timezone": "وحدة زمنية", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "تمكين شبكة الصور التجريبية", "experimental_settings_subtitle": "استخدام على مسؤوليتك الخاصة!", "experimental_settings_title": "تجريبي", + "favorites": "Favorites", "favorites_page_no_favorites": "لم يتم العثور على الأصول المفضلة", "favorites_page_title": "المفضلة", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "تمكين ردود الفعل اللمسية", "haptic_feedback_title": "ردود فعل لمسية", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "إذا كانت هذه هي المرة الأولى التي تستخدم فيها التطبيق، فيرجى التأكد من اختيار ألبوم (ألبومات) احتياطية حتى يتمكن المخطط الزمني من ملء الصور ومقاطع الفيديو في الألبوم (الألبومات).", "home_page_share_err_local": "لا يمكن مشاركة الأصول المحلية عبر الرابط ، سوف يتخطى", "home_page_upload_err_limit": "لا يمكن إلا تحميل 30 أحد الأصول في وقت واحد ، سوف يتخطى", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "خطا في التحميل", "image_viewer_page_state_provider_download_started": "بدأ التنزيل", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "خطأ في المشاركة", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "ألبومات", "library_page_archive": "أرشيف", "library_page_device_albums": "ألبومات على الجهاز", @@ -342,6 +364,7 @@ "motion_photos_page_title": "الصور المتحركة", "multiselect_grid_edit_date_time_err_read_only": "لا يمكن تعديل تاريخ الأصول (المواد) للقراءة فقط، سوف يتخطى", "multiselect_grid_edit_gps_err_read_only": "لا يمكن تعديل موقع الأصول (المواد) للقراءة فقط، سوف يتخطى", + "my_albums": "My albums", "no_assets_to_show": "لا توجد أصول لعرضها", "no_name": "No name", "notification_permission_dialog_cancel": "يلغي", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "منح إذن لتمكين الإخطارات.", "notification_permission_list_tile_enable_button": "تمكين الإخطارات", "notification_permission_list_tile_title": "إذن الإخطار", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "عرض الكل", "partner_page_add_partner": "أضف شريكًا", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "توقف عن مشاركة صورك؟", "partner_page_title": "شريك", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "خلف", "permission_onboarding_continue_anyway": "تواصل على أي حال", "permission_onboarding_get_started": "البدء", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "تم تأمين التصريح! وضعك تمام.", "permission_onboarding_permission_limited": "إذن محدود. للسماح بالنسخ الاحتياطي للتطبيق وإدارة مجموعة المعرض بالكامل، امنح أذونات الصور والفيديو في الإعدادات.", "permission_onboarding_request": "يتطلب التطبيق إذنًا لعرض الصور ومقاطع الفيديو الخاصة بك", + "places": "Places", "preferences_settings_title": "التفضيلات", "profile_drawer_app_logs": "السجلات", "profile_drawer_client_out_of_date_major": "تطبيق الهاتف المحمول قديم.يرجى التحديث إلى أحدث إصدار رئيسي.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "إعدادات", "profile_drawer_sign_out": "خروج", "profile_drawer_trash": "نفايات", + "recently_added": "Recently added", "recently_added_page_title": "أضيف مؤخرا", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "حدث خطأ", + "search_albums": "Search albums", "search_bar_hint": "ابحث عن صورك", "search_filter_apply": "اختار الفلتر ", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "رفع", "shared_link_manage_links": "إدارة الروابط المشتركة", "shared_link_public_album": "الألبوم العام", + "shared_links": "Shared links", "share_done": "منتهي", + "shared_with_me": "Shared with me", "share_invite": "دعوة إلى الألبوم", "sharing_page_album": "ألبومات مشتركة", "sharing_page_description": "قم بإنشاء ألبومات مشتركة لمشاركة الصور ومقاطع الفيديو مع أشخاص في شبكتك.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "قد يزيد التحميل من ثلاث مراحل من أداء التحميل ولكنه يسبب تحميل شبكة أعلى بكثير", "theme_setting_three_stage_loading_title": "تمكين تحميل ثلاث مراحل", "translated_text_options": "خيارات", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "مسح", "trash_page_delete_all": "حذف الكل", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "من فضلك خذ وقتك لزيارة", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "نسخه جديده متاحه للخادم ", + "videos": "Videos", "viewer_remove_from_stack": "حذف من الكومه أو المجموعة", "viewer_stack_use_as_main_asset": "استخدم كأصل رئيسي", "viewer_unstack": "فك الكومه" diff --git a/mobile/assets/i18n/cs-CZ.json b/mobile/assets/i18n/cs-CZ.json index 4a81de75960ab4..6a2c70a2a91a3e 100644 --- a/mobile/assets/i18n/cs-CZ.json +++ b/mobile/assets/i18n/cs-CZ.json @@ -6,6 +6,7 @@ "action_common_save": "Uložit", "action_common_select": "Vybrat", "action_common_update": "Aktualizovat", + "add_a_name": "Přidat název", "add_to_album_bottom_sheet_added": "Přidáno do {album}", "add_to_album_bottom_sheet_already_exists": "Je již v {album}", "advanced_settings_log_level_title": "Úroveň protokolování: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Řešení problémů", "album_info_card_backup_album_excluded": "VYLOUČENO", "album_info_card_backup_album_included": "ZAHRNUTO", + "albums": "Alba", "album_thumbnail_card_item": "1 položka", "album_thumbnail_card_items": "{} položek", "album_thumbnail_card_shared": " · Sdíleno", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Odstranit z alba", "album_viewer_appbar_share_to": "Sdílet na", "album_viewer_page_share_add_users": "Přidat uživatele", + "all": "Vše", "all_people_page_title": "Lidé", "all_videos_page_title": "Videa", "app_bar_signout_dialog_content": "Určitě se chcete odhlásit?", "app_bar_signout_dialog_ok": "Ano", "app_bar_signout_dialog_title": "Odhlásit se", + "archived": "Archivované", "archive_page_no_archived_assets": "Nebyla nalezena žádná archivovaná média", "archive_page_title": "Archív ({})", "asset_action_delete_err_read_only": "Nelze odstranit položky pouze pro čtení, přeskakuji", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Odarchivovat", "control_bottom_app_bar_unfavorite": "Zrušit oblíbení", "control_bottom_app_bar_upload": "Nahrát", + "create_album": "Vytvořit album", "create_album_page_untitled": "Bez názvu", + "create_new": "VYTVOŘIT NOVÉ", "create_shared_album_page_create": "Vytvořit", "create_shared_album_page_share": "Sdílet", "create_shared_album_page_share_add_assets": "PŘIDAT POLOŽKY", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Odstranit sdílený odkaz", "description_input_hint_text": "Přidat popis...", "description_input_submit_error": "Chyba aktualizace popisu, další podrobnosti najdete v logu", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Stahování zrušeno", + "download_complete": "Stahování kompletní", + "download_enqueue": "Stahování ve frontě", + "download_error": "Chyba při stahování", + "download_failed": "Stahování selhalo", + "download_filename": "soubor: {}", + "download_finished": "Stahování dokončeno", + "downloading": "Stahování...", + "downloading_media": "Stahování média", + "download_notfound": "Stahování nebylo nalezeno", + "download_paused": "Stahování pozastaveno", + "download_started": "Stahování zahájeno", + "download_sucess": "Stažení úspěšné", + "download_sucess_android": "Média byla stažena do DCIM/Immich", + "download_waiting_to_retry": "Čekání na opakovaný pokus", "edit_date_time_dialog_date_time": "Datum a čas", "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Upravit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Povolení experimentální mřížky fotografií", "experimental_settings_subtitle": "Používejte na vlastní riziko!", "experimental_settings_title": "Experimentální", + "favorites": "Oblíbené", "favorites_page_no_favorites": "Nebyla nalezena žádná oblíbená média", "favorites_page_title": "Oblíbené", "filename_search": "Název nebo přípona souboru", + "filter": "Filtr", "haptic_feedback_switch": "Povolit dotykovou zpětnou vazbu", "haptic_feedback_title": "Dotyková zpětná vazba", "header_settings_add_header_tip": "Přidat hlavičku", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Pokud aplikaci používáte poprvé, nezapomeňte si vybrat zálohovaná alba, aby se na časové ose mohly nacházet fotografie a videa z vybraných alb.", "home_page_share_err_local": "Nelze sdílet místní položky prostřednictvím odkazu, přeskakuji", "home_page_upload_err_limit": "Lze nahrát nejvýše 30 položek najednou, přeskakuji", + "ignore_icloud_photos": "Ignorovat fotografie na iCloudu", + "ignore_icloud_photos_description": "Fotografie uložené na iCloudu se nebudou nahrávat na Immich server", "image_saved_successfully": "Obrázek uložen", "image_viewer_page_state_provider_download_error": "Chyba stahování", "image_viewer_page_state_provider_download_started": "Stahování zahájeno", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Chyba sdílení", "invalid_date": "Chybné datum", "invalid_date_format": "Chybný formát data", + "library": "Knihovna", "library_page_albums": "Alba", "library_page_archive": "Archív", "library_page_device_albums": "Alba v zařízení", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Pohyblivé fotky", "multiselect_grid_edit_date_time_err_read_only": "Nelze upravit datum položek pouze pro čtení, přeskakuji", "multiselect_grid_edit_gps_err_read_only": "Nelze upravit polohu položek pouze pro čtení, přeskakuji", + "my_albums": "Moje alba", "no_assets_to_show": "Žádné položky k zobrazení", "no_name": "Bez jména", "notification_permission_dialog_cancel": "Zrušit", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Udělte oprávnění k aktivaci oznámení.", "notification_permission_list_tile_enable_button": "Povolit oznámení", "notification_permission_list_tile_title": "Povolení oznámení", + "on_this_device": "V tomto zařízení", "partner_list_user_photos": "Fotografie uživatele {user}", "partner_list_view_all": "Zobrazit všechny", "partner_page_add_partner": "Přidat partnera", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} již nebude mít přístup k vašim fotografiím.", "partner_page_stop_sharing_title": "Přestat sdílet vaše fotografie?", "partner_page_title": "Partner", + "partners": "Partneři", + "people": "Lidé", "permission_onboarding_back": "Zpět", "permission_onboarding_continue_anyway": "Přesto pokračovat", "permission_onboarding_get_started": "Začít", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Přístup povolen! Vše je připraveno.", "permission_onboarding_permission_limited": "Přístup omezen. Chcete-li používat Immich k zálohování a správě celé vaší kolekce galerií, povolte v nastavení přístup k fotkám a videím.", "permission_onboarding_request": "Immich potřebuje přístup k zobrazení vašich fotek a videí.", + "places": "Místa", "preferences_settings_title": "Předvolby", "profile_drawer_app_logs": "Logy", "profile_drawer_client_out_of_date_major": "Mobilní aplikace je zastaralá. Aktualizujte ji na nejnovější hlavní verzi.", @@ -383,10 +410,12 @@ "profile_drawer_settings": "Nastavení", "profile_drawer_sign_out": "Odhlásit se", "profile_drawer_trash": "Vyhodit", + "recently_added": "Nedávno přidané", "recently_added_page_title": "Nedávno přidané", "save_to_gallery": "Uložit do galerie", "scaffold_body_error_occurred": "Došlo k chybě", - "search_bar_hint": "Prohledejte své fotky", + "search_albums": "Vyhledávejte alba", + "search_bar_hint": "Vyhledávejte svoje fotky", "search_filter_apply": "Použít filtr", "search_filter_camera": "Fotoaparát", "search_filter_camera_make": "Výrobce", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Nahrát", "shared_link_manage_links": "Spravovat sdílené odkazy", "shared_link_public_album": "Veřejné album", + "shared_links": "Sdílené odkazy", "share_done": "Hotovo", + "shared_with_me": "Sdílené se mnou", "share_invite": "Pozvat do alba", "sharing_page_album": "Sdílená alba", "sharing_page_description": "Vytvářejte sdílená alba a sdílejte fotografie a videa s lidmi ve vaší síti.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Třístupňové načítání může zvýšit výkonnost načítání, ale vede k výrazně vyššímu zatížení sítě.", "theme_setting_three_stage_loading_title": "Povolení třístupňového načítání", "translated_text_options": "Možnosti", + "trash": "Koš", "trash_emptied": "Koš vyprázdněn", "trash_page_delete": "Smazat", "trash_page_delete_all": "Smazat všechny", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "najděte si čas na návštěvu ", "version_announcement_overlay_text_3": " a ujistěte se, že vaše konfigurace docker-compose a .env je aktuální, abyste předešli nesprávné konfiguraci, zvláště pokud používáte WatchTower nebo jakýkoli mechanismus, který podporuje automatické aktualizace serverových aplikací.", "version_announcement_overlay_title": "K dispozici je nová verze serveru \uD83C\uDF89", + "videos": "Videa", "viewer_remove_from_stack": "Odstranit ze zásobníku", "viewer_stack_use_as_main_asset": "Použít jako hlavní položku", "viewer_unstack": "Rozbalit zásobník" diff --git a/mobile/assets/i18n/da-DK.json b/mobile/assets/i18n/da-DK.json index 20c3c43b09410d..aa39ed54bca0a9 100644 --- a/mobile/assets/i18n/da-DK.json +++ b/mobile/assets/i18n/da-DK.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Opdater", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Tilføjet til {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", "advanced_settings_log_level_title": "Logniveau: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Fejlsøgning", "album_info_card_backup_album_excluded": "EKSKLUDERET", "album_info_card_backup_album_included": "INKLUDERET", + "albums": "Albums", "album_thumbnail_card_item": "1 genstand", "album_thumbnail_card_items": "{} genstande", "album_thumbnail_card_shared": ". Delt", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Fjern fra album", "album_viewer_appbar_share_to": "Del til", "album_viewer_page_share_add_users": "Tilføj brugere", + "all": "All", "all_people_page_title": "Personer", "all_videos_page_title": "Videoer", "app_bar_signout_dialog_content": "Er du sikker på, du vil logge ud?", "app_bar_signout_dialog_ok": "Ja", "app_bar_signout_dialog_title": "Log ud", + "archived": "Archived", "archive_page_no_archived_assets": "Ingen arkiverede elementer blev fundet", "archive_page_title": "Arkivér ({})", "asset_action_delete_err_read_only": "Kan ikke slette kun læselige elementer. Springer over", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Afakivér", "control_bottom_app_bar_unfavorite": "Fjern favorit", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Uden titel", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Opret", "create_shared_album_page_share": "Del", "create_shared_album_page_share_add_assets": "TILFØJ ELEMENT", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Slet delt link", "description_input_hint_text": "Tilføj en beskrivelse...", "description_input_submit_error": "Fejl med at opdatere beskrivelsen. Tjek loggen for flere detaljer", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", + "download_error": "Fejl med download", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", + "download_started": "Download startet", + "download_sucess": "Download færdig", + "download_sucess_android": "Mediet er blevet downloadet til DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Dato og klokkeslæt", "edit_date_time_dialog_timezone": "Tidszone", "edit_image_title": "Rediger", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Aktiver eksperimentelt fotogitter", "experimental_settings_subtitle": "Brug på eget ansvar!", "experimental_settings_title": "Eksperimentelle", + "favorites": "Favorites", "favorites_page_no_favorites": "Ingen favoritter blev fundet", "favorites_page_title": "Favoritter", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Slå haptisk feedback til", "haptic_feedback_title": "Haptisk feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Hvis det er din første gang i appen, bedes du vælge en sikkerhedskopi af albummer så tidlinjen kan blive fyldt med billeder og videoer fra albummerne.", "home_page_share_err_local": "Kan ikke dele lokale elementer via link, springer over", "home_page_upload_err_limit": "Det er kun muligt at lave sikkerhedskopi af 30 elementer ad gangen. Springer over", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Billede gemt", "image_viewer_page_state_provider_download_error": "Fejl ved download", "image_viewer_page_state_provider_download_started": "Download startet", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Delingsfejl", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albummer", "library_page_archive": "Arkiv", "library_page_device_albums": "Albummer på enhed", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Bevægelsesbilleder", "multiselect_grid_edit_date_time_err_read_only": "Kan ikke redigere datoen på kun læselige elementer. Springer over", "multiselect_grid_edit_gps_err_read_only": "Kan ikke redigere lokation af kun læselige elementer. Springer over", + "my_albums": "My albums", "no_assets_to_show": "Ingen elementer at vise", "no_name": "Intet navn", "notification_permission_dialog_cancel": "Annuller", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Tillad at bruge notifikationer.", "notification_permission_list_tile_enable_button": "Slå notifikationer til", "notification_permission_list_tile_title": "Notifikationstilladelser", + "on_this_device": "On this device", "partner_list_user_photos": "{user}s billeder", "partner_list_view_all": "Se alle", "partner_page_add_partner": "Tilføj partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} vil ikke længere have adgang til dine billeder.", "partner_page_stop_sharing_title": "Stop med at dele dine billeder?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Tilbage", "permission_onboarding_continue_anyway": "Fortsæt alligevel", "permission_onboarding_get_started": "Kom i gang", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Tilladelse givet! Du er nu klar.", "permission_onboarding_permission_limited": "Tilladelse begrænset. For at lade Immich lave sikkerhedskopi og styre hele dit galleri, skal der gives tilladelse til billeder og videoer i indstillinger.", "permission_onboarding_request": "Immich kræver tilliadelse til at se dine billeder og videoer.", + "places": "Places", "preferences_settings_title": "Præferencer", "profile_drawer_app_logs": "Log", "profile_drawer_client_out_of_date_major": "Mobilapp er forældet. Opdater venligst til den nyeste større version", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Indstillinger", "profile_drawer_sign_out": "Log ud", "profile_drawer_trash": "Papirkurv", + "recently_added": "Recently added", "recently_added_page_title": "Nyligt tilføjet", "save_to_gallery": "Gem til galleri", "scaffold_body_error_occurred": "Der opstod en fejl", + "search_albums": "Search albums", "search_bar_hint": "Søg i dine billeder", "search_filter_apply": "Tilføj filter", "search_filter_camera": "Kamera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Håndter delte links", "shared_link_public_album": "Offentligt album", + "shared_links": "Shared links", "share_done": "Færdig", + "shared_with_me": "Shared with me", "share_invite": "Inviter til album", "sharing_page_album": "Delt albums", "sharing_page_description": "Opret delte albummer for at dele billeder og video med personer på dit netværk.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Tre-trins indlæsning kan øge ydeevnen, men kan ligeledes føre til højere netværksbelastning", "theme_setting_three_stage_loading_title": "Slå tre-trins indlæsning til", "translated_text_options": "Handlinger", + "trash": "Trash", "trash_emptied": "Tømte papirkurven", "trash_page_delete": "Slet", "trash_page_delete_all": "Slet alt", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": ". Besøg venligst ", "version_announcement_overlay_text_3": " for at sikre dig, at din dockercompose- og .env-fil er opdateret, så der undgås fejlkonfiguration, specielt hvis du bruger WatchTower eller lignede.", "version_announcement_overlay_title": "Ny serverversion er tilgængelig \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Fjern fra stak", "viewer_stack_use_as_main_asset": "Brug som hovedelement", "viewer_unstack": "Fjern fra stak" diff --git a/mobile/assets/i18n/de-DE.json b/mobile/assets/i18n/de-DE.json index bb2ed31f8a456b..b3452889fdecb2 100644 --- a/mobile/assets/i18n/de-DE.json +++ b/mobile/assets/i18n/de-DE.json @@ -6,6 +6,7 @@ "action_common_save": "Speichern", "action_common_select": "Auswählen ", "action_common_update": "Aktualisieren", + "add_a_name": "Einen Namen hinzufügen", "add_to_album_bottom_sheet_added": "Zu {album} hinzugefügt", "add_to_album_bottom_sheet_already_exists": "Bereits in {album}", "advanced_settings_log_level_title": "Log-Level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Fehlersuche", "album_info_card_backup_album_excluded": "AUSGESCHLOSSEN", "album_info_card_backup_album_included": "EINGESCHLOSSEN", + "albums": "Alben", "album_thumbnail_card_item": "1 Element", "album_thumbnail_card_items": "{} Elemente", "album_thumbnail_card_shared": " · Geteilt", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Vom Album entfernen", "album_viewer_appbar_share_to": "Teile über", "album_viewer_page_share_add_users": "Nutzer hinzufügen", + "all": "Alle", "all_people_page_title": "Personen", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Bist du dir sicher, dass du dich abmelden möchtest?", "app_bar_signout_dialog_ok": "Ja", "app_bar_signout_dialog_title": "Abmelden", + "archived": "Archiviert", "archive_page_no_archived_assets": "Keine archivierten Inhalte gefunden", "archive_page_title": "Archiv ({})", "asset_action_delete_err_read_only": "Schreibgeschützte Inhalte können nicht gelöscht werden, überspringen...", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Dearchivieren", "control_bottom_app_bar_unfavorite": "Aus Favoriten entfernen", "control_bottom_app_bar_upload": "Hochladen", + "create_album": "Album erstellen", "create_album_page_untitled": "Unbenannt", + "create_new": "NEUES ERSTELLEN", "create_shared_album_page_create": "Erstellen", "create_shared_album_page_share": "Teilen", "create_shared_album_page_share_add_assets": "INHALTE HINZUFÜGEN", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Geteilten Link löschen", "description_input_hint_text": "Beschreibung hinzufügen...", "description_input_submit_error": "Beschreibung konnte nicht geändert werden, bitte im Log für mehr Details nachsehen.", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Download abgebrochen!", + "download_complete": "Download vollständig!", + "download_enqueue": "Download in die Warteschlange gesetzt!", + "download_error": "Download fehlerhaft", + "download_failed": "Download fehlerhaft!", + "download_filename": "Datei: {}", + "download_finished": "Download abgeschlossen", + "downloading": "Wird heruntergeladen...", + "downloading_media": "Medien werden heruntergeladen", + "download_notfound": "Download nicht gefunden!", + "download_paused": "Download pausiert!", + "download_started": "Download gestartet", + "download_sucess": "Download erfolgreich", + "download_sucess_android": "Die Datei wurde nach DCIM/Immich heruntergeladen", + "download_waiting_to_retry": "Warte auf erneuten Versuch...", "edit_date_time_dialog_date_time": "Datum und Uhrzeit", "edit_date_time_dialog_timezone": "Zeitzone", "edit_image_title": "Bearbeiten", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Experimentelles Fotogitter aktivieren", "experimental_settings_subtitle": "Benutzung auf eigene Gefahr!", "experimental_settings_title": "Experimentell", + "favorites": "Favoriten", "favorites_page_no_favorites": "Keine favorisierten Inhalte gefunden", "favorites_page_title": "Favoriten", "filename_search": "Dateiname oder Dateityp", + "filter": "Filter", "haptic_feedback_switch": "Haptisches Feedback aktivieren", "haptic_feedback_title": "Haptisches Feedback", "header_settings_add_header_tip": "Header hinzufügen", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Wenn dies das erste Mal ist dass Du Immich nutzt, stelle bitte sicher, dass mindestens ein Album zur Sicherung ausgewählt ist, sodass die Zeitachse mit Fotos und Videos gefüllt werden kann.", "home_page_share_err_local": "Lokale Inhalte können nicht per Link geteilt werden, überspringe", "home_page_upload_err_limit": "Es können max. 30 Elemente gleichzeitig hochgeladen werden, überspringen...", + "ignore_icloud_photos": "iCloud Fotos ignorieren", + "ignore_icloud_photos_description": "Fotos, die in der iCloud gespeichert sind, werden nicht auf den immich Server hochgeladen", "image_saved_successfully": "Bild gespeichert", "image_viewer_page_state_provider_download_error": "Fehler beim Herunterladen", "image_viewer_page_state_provider_download_started": "Download gestartet", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Fehler beim Teilen", "invalid_date": "Ungültiges Datum ", "invalid_date_format": "Ungültiges Datumsformat", + "library": "Bibliothek", "library_page_albums": "Alben", "library_page_archive": "Archiv", "library_page_device_albums": "Alben auf dem Gerät", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Live-Fotos", "multiselect_grid_edit_date_time_err_read_only": "Das Datum und die Uhrzeit von schreibgeschützten Inhalten kann nicht verändert werden, überspringen...", "multiselect_grid_edit_gps_err_read_only": "Der Aufnahmeort von schreibgeschützten Inhalten kann nicht verändert werden, überspringen...", + "my_albums": "Meine Alben", "no_assets_to_show": "Keine Vorschau vorhanden", "no_name": "Kein Name", "notification_permission_dialog_cancel": "Abbrechen", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Erlaube Berechtigung für Benachrichtigungen", "notification_permission_list_tile_enable_button": "Aktiviere Benachrichtigungen", "notification_permission_list_tile_title": "Benachrichtigungs-Berechtigung", + "on_this_device": "Auf diesem Gerät", "partner_list_user_photos": "{user}s Fotos", "partner_list_view_all": "Alle anzeigen", "partner_page_add_partner": "Partner hinzufügen", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} wird nicht mehr auf deine Fotos zugreifen können.", "partner_page_stop_sharing_title": "Deine Fotos nicht mehr teilen?", "partner_page_title": "Partner", + "partners": "Partner", + "people": "Personen", "permission_onboarding_back": "Zurück", "permission_onboarding_continue_anyway": "Trotzdem fortfahren", "permission_onboarding_get_started": "Jetzt starten", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Berechtigung erteilt! Du bist startklar.", "permission_onboarding_permission_limited": "Berechtigungen unzureichend. Um Immich das Sichern von ganzen Sammlungen zu ermöglichen, muss der Zugriff auf alle Fotos und Videos in den Einstellungen erlaubt werden.", "permission_onboarding_request": "Immich benötigt Berechtigung um auf deine Fotos und Videos zuzugreifen.", + "places": "Orte", "preferences_settings_title": "Voreinstellungen", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile-App ist veraltet. Bitte aktualisiere auf die neueste Major-Version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Einstellungen", "profile_drawer_sign_out": "Abmelden", "profile_drawer_trash": "Papierkorb", + "recently_added": "Kürzlich hinzugefügt", "recently_added_page_title": "Zuletzt hinzugefügt", "save_to_gallery": "In Galerie speichern", "scaffold_body_error_occurred": "Ein Fehler ist aufgetreten", + "search_albums": "Suche Alben", "search_bar_hint": "Durchsuche deine Fotos", "search_filter_apply": "Filter anwenden", "search_filter_camera": "Kamera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Hochladen", "shared_link_manage_links": "Geteilte Links verwalten", "shared_link_public_album": "Öffentliches Album", + "shared_links": "Geteilte Links", "share_done": "Fertig", + "shared_with_me": "Mit mir geteilt", "share_invite": "Zum Album einladen", "sharing_page_album": "Geteilte Alben", "sharing_page_description": "Erstelle ein geteiltes Album um Fotos und Videos mit Personen in deinem Netzwerk zu teilen.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Das dreistufige Ladeverfahren kann die Performance beim Laden verbessern, erhöht allerdings den Datenverbrauch deutlich", "theme_setting_three_stage_loading_title": "Dreistufiges Laden aktivieren", "translated_text_options": "Optionen", + "trash": "Papierkorb", "trash_emptied": "Geleerter Papierkorb", "trash_page_delete": "Löschen", "trash_page_delete_all": "Alle löschen", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "Bitte nehme dir die Zeit und lies das ", "version_announcement_overlay_text_3": " und achte darauf, dass deine docker-compose und .env Dateien aktuell sind, vor allem wenn du ein System für automatische Updates benutzt (z.B. Watchtower).", "version_announcement_overlay_title": "Neue Server-Version verfügbar \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Aus Stapel entfernen", "viewer_stack_use_as_main_asset": "An Stapelanfang", "viewer_unstack": "Stapel aufheben" diff --git a/mobile/assets/i18n/el-GR.json b/mobile/assets/i18n/el-GR.json index 88426a6076ba9a..5d8d077fab29f8 100644 --- a/mobile/assets/i18n/el-GR.json +++ b/mobile/assets/i18n/el-GR.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Ενημέρωση", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Προστέθηκε στο {album}", "add_to_album_bottom_sheet_already_exists": "Ήδη στο {album}", "advanced_settings_log_level_title": "Επίπεδο καταγραφής: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Αντιμετώπιση προβλημάτων", "album_info_card_backup_album_excluded": "ΕΞΑΙΡΟΥΜΕΝΟ", "album_info_card_backup_album_included": "ΣΥΜΠΕΡΙΛΑΜΒΑΝΟΜΕΝΟ", + "albums": "Albums", "album_thumbnail_card_item": "1 αντικείμενο", "album_thumbnail_card_items": "{} αντικείμενα", "album_thumbnail_card_shared": "· Κοινόχρηστο", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Αφαίρεση από άλμπουμ", "album_viewer_appbar_share_to": "Κοινοποίηση σε", "album_viewer_page_share_add_users": "Προσθήκη χρηστών", + "all": "All", "all_people_page_title": "Άτομα", "all_videos_page_title": "Βίντεο", "app_bar_signout_dialog_content": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε;", "app_bar_signout_dialog_ok": "Ναι", "app_bar_signout_dialog_title": "Αποσύνδεση", + "archived": "Archived", "archive_page_no_archived_assets": "Δε βρέθηκαν αρχειοθετημένα στοιχεία", "archive_page_title": "Αρχειοθέτηση ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Αναίρεση αρχειοθέτησης", "control_bottom_app_bar_unfavorite": "Κατάργηση από τα αγαπημένα", "control_bottom_app_bar_upload": "Μεταφόρτωση", + "create_album": "Create album", "create_album_page_untitled": "Χωρίς τίτλο", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Δημιουργία", "create_shared_album_page_share": "Κοινοποίηση", "create_shared_album_page_share_add_assets": "ΠΡΟΣΘΗΚΗ ΣΤΟΙΧΕΙΩΝ", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Διαγραφή Κοινοποιημένου Συνδέσμου", "description_input_hint_text": "Προσθήκη περιγραφής...", "description_input_submit_error": "Σφάλμα κατά την ενημέρωση της περιγραφής, ελέγξτε το αρχείο καταγραφής για περισσότερες λεπτομέρειες", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Ημερομηνία και Ώρα", "edit_date_time_dialog_timezone": "Ζώνη ώρας", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Ενεργοποίηση πειραματικού πλέγματος φωτογραφιών", "experimental_settings_subtitle": "Χρησιμοποιείτε με δική σας ευθύνη!", "experimental_settings_title": "Πειραματικό", + "favorites": "Favorites", "favorites_page_no_favorites": "Δεν βρέθηκαν αγαπημένα στοιχεία", "favorites_page_title": "Αγαπημένα", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Προσθήκη συντρόφου", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "Ο/Η {} δεν θα μπορεί πλέον να δει τις φωτογραφίες σας.", "partner_page_stop_sharing_title": "Θέλετε να σταματήσετε να μοιράζεστε τις φωτογραφίες σας;", "partner_page_title": "Σύντροφος", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Πίσω", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index bb4f3efd267c6f..0075f65de0557f 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,18 +618,8 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack", - "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" -} + "viewer_unstack": "Un-Stack" +} \ No newline at end of file diff --git a/mobile/assets/i18n/es-ES.json b/mobile/assets/i18n/es-ES.json index 1943116b4ff5f1..88db7f9068eff9 100644 --- a/mobile/assets/i18n/es-ES.json +++ b/mobile/assets/i18n/es-ES.json @@ -3,9 +3,10 @@ "action_common_cancel": "Cancelar", "action_common_clear": "Limpiar", "action_common_confirm": "Confirmar", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "Guardar", + "action_common_select": "Seleccionar", "action_common_update": "Actualizar", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Nivel de registro: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Solución de problemas", "album_info_card_backup_album_excluded": "EXCLUIDOS", "album_info_card_backup_album_included": "INCLUIDOS", + "albums": "Albums", "album_thumbnail_card_item": "1 elemento", "album_thumbnail_card_items": "{} elementos", "album_thumbnail_card_shared": "Compartido", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Eliminar del álbum ", "album_viewer_appbar_share_to": "Compartir Con", "album_viewer_page_share_add_users": "Agregar usuarios", + "all": "All", "all_people_page_title": "Personas", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "¿Estás seguro que quieres cerrar sesión?", "app_bar_signout_dialog_ok": "Sí", "app_bar_signout_dialog_title": "Cerrar sesión", + "archived": "Archived", "archive_page_no_archived_assets": "No se encontraron elementos archivados", "archive_page_title": "Archivo ({})", "asset_action_delete_err_read_only": "No se pueden borrar el archivo(s) de solo lectura, omitiendo", @@ -55,11 +59,11 @@ "asset_list_settings_subtitle": "Configuraciones del diseño de la cuadrícula de fotos", "asset_list_settings_title": "Cuadrícula de fotos", "asset_restored_successfully": "Asset restored successfully", - "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently": "\n{} elementos(s) eliminado(s) permanentemente", "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", "assets_restored_successfully": "{} asset(s) restored successfully", - "assets_trashed": "{} asset(s) trashed", + "assets_trashed": "{} elemento(s) eliminado(s)", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", "asset_viewer_settings_title": "Visor de Archivos", "backup_album_selection_page_albums_device": "Álbumes en el dispositivo ({})", @@ -151,11 +155,11 @@ "change_password_form_password_mismatch": "Las contraseñas no coinciden", "change_password_form_reenter_new_password": "Vuelve a ingresar la nueva contraseña", "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", + "client_cert_enter_password": "Introduzca contraseña", + "client_cert_import": "Importar", "client_cert_import_success_msg": "Client certificate is imported", "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", + "client_cert_remove": "Eliminar", "client_cert_remove_msg": "Client certificate is removed", "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", "client_cert_title": "SSL Client Certificate", @@ -164,7 +168,7 @@ "common_create_new_album": "Crear nuevo álbum", "common_server_error": "Por favor, verifica tu conexión de red, asegúrate de que el servidor esté accesible y las versiones de la aplicación y del servidor sean compatibles.", "common_shared": "Compartido", - "contextual_search": "Sunrise on the beach", + "contextual_search": "Amanecer en la playa", "control_bottom_app_bar_add_to_album": "Agregar al álbum", "control_bottom_app_bar_album_info": "{} elementos", "control_bottom_app_bar_album_info_shared": "{} elementos · Compartidos", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Desarchivar", "control_bottom_app_bar_unfavorite": "Retirar favorito", "control_bottom_app_bar_upload": "Subir", + "create_album": "Create album", "create_album_page_untitled": "Sin título", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Crear", "create_shared_album_page_share": "Compartir", "create_shared_album_page_share_add_assets": "AGREGAR ELEMENTOS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", + "download_error": "Error al descargar", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", + "download_started": "Descarga iniciada", + "download_sucess": "Descarga Exitosa", + "download_sucess_android": "Los archivos se han descargado en DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Fecha y Hora", "edit_date_time_dialog_timezone": "Zona horaria", "edit_image_title": "Editar", @@ -229,18 +246,20 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No se encontraron elementos marcados como favoritos", "favorites_page_title": "Favoritos", - "filename_search": "File name or extension", + "filename_search": "Nombre o extensión", + "filter": "Filter", "haptic_feedback_switch": "Activar respuesta háptica", "haptic_feedback_title": "Respuesta Háptica", "header_settings_add_header_tip": "Añadir cabecera", - "header_settings_field_validator_msg": "Value cannot be empty", - "header_settings_header_name_input": "Header name", - "header_settings_header_value_input": "Header value", + "header_settings_field_validator_msg": "El valor no puede estar vacío", + "header_settings_header_name_input": "Nombre de la cabecera", + "header_settings_header_value_input": "Valor de la cabecera", "header_settings_page_title": "Proxy Headers", "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", - "headers_settings_tile_title": "Custom proxy headers", + "headers_settings_tile_title": "Cabeceras de proxy personalizadas", "home_page_add_to_album_conflicts": "{added} elementos agregados al álbum {album}.{failed} elementos ya existen en el álbum.", "home_page_add_to_album_err_local": "Aún no se pueden agregar elementos locales a álbumes, omitiendo", "home_page_add_to_album_success": "{added} elementos agregados al álbum {album}. ", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Si esta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.", "home_page_share_err_local": "No se pueden compartir elementos locales a través de un enlace, omitiendo", "home_page_upload_err_limit": "Solo se pueden subir 30 elementos simultáneamente, omitiendo", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Imágenes guardas", "image_viewer_page_state_provider_download_error": "Error de descarga", "image_viewer_page_state_provider_download_started": "Descarga Iniciada", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Error al compartir", "invalid_date": "Fecha incorrecta", "invalid_date_format": "Formato de fecha incorrecto", + "library": "Library", "library_page_albums": "Álbumes", "library_page_archive": "Archivo", "library_page_device_albums": "Álbumes en el dispositivo", @@ -342,14 +364,16 @@ "motion_photos_page_title": "Foto en Movimiento", "multiselect_grid_edit_date_time_err_read_only": "No se puede cambiar la fecha del archivo(s) de solo lectura, omitiendo", "multiselect_grid_edit_gps_err_read_only": "No se puede cambiar la localización de archivos de solo lectura. Saltando.", + "my_albums": "My albums", "no_assets_to_show": "No hay elementos a mostrar", - "no_name": "No name", + "no_name": "Sin nombre", "notification_permission_dialog_cancel": "Cancelar", "notification_permission_dialog_content": "Para activar las notificaciones, ve a Configuración y selecciona permitir.", "notification_permission_dialog_settings": "Ajustes", "notification_permission_list_tile_content": "Concede permiso para habilitar las notificaciones.", "notification_permission_list_tile_enable_button": "Permitir notificaciones", "notification_permission_list_tile_title": "Permisos de Notificacion", + "on_this_device": "On this device", "partner_list_user_photos": "Fotos de {user}", "partner_list_view_all": "Ver todas", "partner_page_add_partner": "Agregar compañero", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ya no podrá acceder a tus fotos", "partner_page_stop_sharing_title": "¿Dejar de compartir tus fotos?", "partner_page_title": "Compañero", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Volver", "permission_onboarding_continue_anyway": "Continuar de todos modos", "permission_onboarding_get_started": "Empezar", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "¡Permiso concedido! Todo listo.", "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", + "places": "Places", "preferences_settings_title": "Preferencias", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "La app está desactualizada. Por favor actualiza a la última versión principal.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Configuración", "profile_drawer_sign_out": "Cerrar Sesión", "profile_drawer_trash": "Papelera", + "recently_added": "Recently added", "recently_added_page_title": "Recién Agregadas", "save_to_gallery": "Guardado en la galería", "scaffold_body_error_occurred": "Ha ocurrido un error", + "search_albums": "Search albums", "search_bar_hint": "Busca tus fotos", "search_filter_apply": "Aplicar filtros", "search_filter_camera": "Cámara", @@ -410,8 +439,8 @@ "search_filter_media_type_image": "Imagen", "search_filter_media_type_title": "Select media type", "search_filter_media_type_video": "Vídeo", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "Personas", + "search_filter_people_title": "Seleccionar personas", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", "search_page_motion_photos": "Foto en Movimiento", @@ -466,7 +495,7 @@ "setting_notifications_total_progress_title": "Mostrar progreso total de copia de seguridad en segundo plano", "setting_pages_app_bar_settings": "Ajustes", "settings_require_restart": "Por favor, reinicia Immich para aplicar este ajuste", - "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", + "setting_video_viewer_looping_subtitle": "Habilitar reproducción en bucle del video en la vista detallada", "setting_video_viewer_looping_title": "Bucle", "setting_video_viewer_title": "Vídeos", "share_add": "Agregar", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Subir", "shared_link_manage_links": "Administrar enlaces compartidos", "shared_link_public_album": "Álbum público ", + "shared_links": "Shared links", "share_done": "Hecho", + "shared_with_me": "Shared with me", "share_invite": "Invitar al álbum", "sharing_page_album": "Álbumes compartidos", "sharing_page_description": "Crea álbumes compartidos para compartir fotos y vídeos con las personas de tu red.", @@ -540,7 +571,7 @@ "sharing_silver_appbar_shared_links": "Enlaces compartidos", "sharing_silver_appbar_share_partner": "Compartir con el compañero", "sync": "Sincronizar", - "sync_albums": "Sync albums", + "sync_albums": "Sincronizar álbumes", "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", "tab_controller_nav_library": "Biblioteca", @@ -556,14 +587,15 @@ "theme_setting_image_viewer_quality_title": "Calidad del visor de imágenes", "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", "theme_setting_primary_color_title": "Primary color", - "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_system_primary_color_title": "Usar color del sistema", "theme_setting_system_theme_switch": "Automático (seguir ajuste del sistema)", "theme_setting_theme_subtitle": "Elige la configuración del tema de la aplicación", "theme_setting_theme_title": "Tema", "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", - "trash_emptied": "Emptied trash", + "trash": "Trash", + "trash_emptied": "Papelera vaciada", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", "trash_page_empty_trash_btn": "Vaciar papelera", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "por favor, tómate tu tiempo para visitar las ", "version_announcement_overlay_text_3": " y asegúrate de que la configuración de docker-compose y .env estén actualizadas para evitar cualquier error de configuración, especialmente si utilizas WatchTower o cualquier mecanismo que actualice automáticamente la aplicación del servidor.", "version_announcement_overlay_title": "Nueva versión del servidor disponible \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Quitar de la pila", "viewer_stack_use_as_main_asset": "Usar como elemento principal", "viewer_unstack": "Desapilar" diff --git a/mobile/assets/i18n/es-MX.json b/mobile/assets/i18n/es-MX.json index 8361e9a285107b..8c07c6a3623ed8 100644 --- a/mobile/assets/i18n/es-MX.json +++ b/mobile/assets/i18n/es-MX.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Solución de problemas", "album_info_card_backup_album_excluded": "EXCLUIDOS", "album_info_card_backup_album_included": "INCLUIDOS", + "albums": "Albums", "album_thumbnail_card_item": "1 elemento", "album_thumbnail_card_items": "{} elementos", "album_thumbnail_card_shared": " · Compartido", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Eliminar del álbum", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Agregar usuarios", + "all": "All", "all_people_page_title": "Personas", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "¿Estás seguro que quieres cerrar sesión?", "app_bar_signout_dialog_ok": "Sí", "app_bar_signout_dialog_title": "Cerrar sesión", + "archived": "Archived", "archive_page_no_archived_assets": "No se encontraron recursos archivados", "archive_page_title": "Archivo ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Desarchivar", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Subir", + "create_album": "Create album", "create_album_page_untitled": "Sin título", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Crear", "create_shared_album_page_share": "Compartir", "create_shared_album_page_share_add_assets": "AGREGAR ARCHIVOS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No se encontraron recursos marcados como favoritos", "favorites_page_title": "Favoritos", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Si esta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.", "home_page_share_err_local": "No se pueden compartir activos locales a través de un enlace, omitiendo", "home_page_upload_err_limit": "Solo se pueden subir 30 elementos simultáneamente, omitiendo", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Error de descarga", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Error al compartir", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Álbumes", "library_page_archive": "Archivo", "library_page_device_albums": "Álbumes en el dispositivo", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Foto en Movimiento", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancelar", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Concede permiso para habilitar las notificaciones.", "notification_permission_list_tile_enable_button": "Permitir notificaciones", "notification_permission_list_tile_title": "Permisos de Notificacion", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Agregar compañero", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ya no podrá acceder a tus fotos", "partner_page_stop_sharing_title": "¿Dejar de compartir tus fotos?", "partner_page_title": "Compañero", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Volver", "permission_onboarding_continue_anyway": "Continuar de todos modos", "permission_onboarding_get_started": "Empezar", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "¡Permiso concedido! Todo listo.", "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Configuración", "profile_drawer_sign_out": "Cerrar sesión", "profile_drawer_trash": "Papelera", + "recently_added": "Recently added", "recently_added_page_title": "Recién Agregadas", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Busca tus fotos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Administrar enlaces compartidos", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Hecho", + "shared_with_me": "Shared with me", "share_invite": "Invitar al álbum", "sharing_page_album": "Álbumes compartidos", "sharing_page_description": "Crea álbumes compartidos para compartir fotos y videos con personas de tu red.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "por favor, tómate tu tiempo para visitar las ", "version_announcement_overlay_text_3": " y asegúrate de que la configuración de docker-compose y .env estén actualizadas para evitar cualquier error de configuración, especialmente si utilizas WatchTower o cualquier mecanismo que actualice automáticamente la aplicación del servidor.", "version_announcement_overlay_title": "Nueva versión del servidor disponible \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Quitar de la pila", "viewer_stack_use_as_main_asset": "Usar como elemento principal", "viewer_unstack": "Desapilar" diff --git a/mobile/assets/i18n/es-PE.json b/mobile/assets/i18n/es-PE.json index cee06c9512cd26..23eaa437ff0ed5 100644 --- a/mobile/assets/i18n/es-PE.json +++ b/mobile/assets/i18n/es-PE.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Solución de problemas", "album_info_card_backup_album_excluded": "EXCLUIDOS", "album_info_card_backup_album_included": "INCLUIDOS", + "albums": "Albums", "album_thumbnail_card_item": "1 elemento", "album_thumbnail_card_items": "{} elementos", "album_thumbnail_card_shared": " · Compartido", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Eliminar del álbum", "album_viewer_appbar_share_to": "Compartir A", "album_viewer_page_share_add_users": "Agregar usuarios", + "all": "All", "all_people_page_title": "Personas", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "¿Estás seguro que quieres cerrar sesión?", "app_bar_signout_dialog_ok": "Sí", "app_bar_signout_dialog_title": "Cerrar sesión", + "archived": "Archived", "archive_page_no_archived_assets": "No se encontraron recursos archivados", "archive_page_title": "Archivo ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Desarchivar", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Subir", + "create_album": "Create album", "create_album_page_untitled": "Sin título", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Crear", "create_shared_album_page_share": "Compartir", "create_shared_album_page_share_add_assets": "AGREGAR ARCHIVOS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "Úsalo bajo tu responsabilidad", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No se encontraron recursos marcados como favoritos", "favorites_page_title": "Favoritos", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Si esta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.", "home_page_share_err_local": "No se pueden compartir activos locales a través de un enlace, omitiendo", "home_page_upload_err_limit": "Solo se pueden subir 30 elementos simultáneamente, omitiendo", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Error de descarga", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Error al compartir", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Álbumes", "library_page_archive": "Archivo", "library_page_device_albums": "Álbumes en el dispositivo", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Foto en Movimiento", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancelar", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Concede permiso para habilitar las notificaciones.", "notification_permission_list_tile_enable_button": "Permitir notificaciones", "notification_permission_list_tile_title": "Permisos de Notificacion", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Agregar compañero", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ya no podrá acceder a tus fotos", "partner_page_stop_sharing_title": "¿Dejar de compartir tus fotos?", "partner_page_title": "Compañero", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Volver", "permission_onboarding_continue_anyway": "Continuar de todos modos", "permission_onboarding_get_started": "Empezar", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "¡Permiso concedido! Todo listo.", "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Configuración", "profile_drawer_sign_out": "Cerrar sesión", "profile_drawer_trash": "Papelera", + "recently_added": "Recently added", "recently_added_page_title": "Recién Agregadas", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Busca tus fotos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Administrar enlaces compartidos", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Hecho", + "shared_with_me": "Shared with me", "share_invite": "Invitar al álbum", "sharing_page_album": "Álbumes compartidos", "sharing_page_description": "Crea álbumes compartidos para compartir fotos y videos con personas de tu red.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "por favor, tómate tu tiempo para visitar las ", "version_announcement_overlay_text_3": " y asegúrate de que la configuración de docker-compose y .env estén actualizadas para evitar cualquier error de configuración, especialmente si utilizas WatchTower o cualquier mecanismo que actualice automáticamente la aplicación del servidor.", "version_announcement_overlay_title": "Nueva versión del servidor disponible \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Quitar de la pila", "viewer_stack_use_as_main_asset": "Usar como elemento principal", "viewer_unstack": "Desapilar" diff --git a/mobile/assets/i18n/es-US.json b/mobile/assets/i18n/es-US.json index 8cfae94c005d12..61c84d0054cdc0 100644 --- a/mobile/assets/i18n/es-US.json +++ b/mobile/assets/i18n/es-US.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Agregado a {album}", "add_to_album_bottom_sheet_already_exists": "Ya se encuentra en {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Solución de problemas", "album_info_card_backup_album_excluded": "EXCLUIDOS", "album_info_card_backup_album_included": "INCLUIDOS", + "albums": "Albums", "album_thumbnail_card_item": "1 elemento", "album_thumbnail_card_items": "{} elementos", "album_thumbnail_card_shared": " · Compartido", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remover del álbum", "album_viewer_appbar_share_to": "Compartir con", "album_viewer_page_share_add_users": "Agregar usuarios", + "all": "All", "all_people_page_title": "Personas", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "¿Estás seguro de que quieres cerrar sesión?", "app_bar_signout_dialog_ok": "Sí", "app_bar_signout_dialog_title": "Cerrar sesión", + "archived": "Archived", "archive_page_no_archived_assets": "No se encontraron recursos archivados", "archive_page_title": "Archivo ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Desarchivar", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Subir", + "create_album": "Create album", "create_album_page_untitled": "Sin título", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Crear", "create_shared_album_page_share": "Compartir", "create_shared_album_page_share_add_assets": "AGREGAR RECURSOS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Eliminar enlace compartido", "description_input_hint_text": "Agregar descripción...", "description_input_submit_error": "Error al actualizar la descripción, verifica el registro para obtener más detalles", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Habilitar cuadrícula fotográfica experimental", "experimental_settings_subtitle": "¡Úsalo bajo tu propio riesgo!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No se encontraron recursos marcados como favoritos", "favorites_page_title": "Favoritos", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Si ésta es la primera vez que usas la app, por favor, asegúrate de elegir un álbum de respaldo para que la línea de tiempo pueda cargar fotos y videos en los álbumes.", "home_page_share_err_local": "No se pueden compartir activos locales a través de un enlace, omitiendo", "home_page_upload_err_limit": "Sólo se pueden subir un máximo de 30 recursos a la vez, omitiendo", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Error de descarga", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Error al compartir", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Álbumes", "library_page_archive": "Archivo", "library_page_device_albums": "Álbumes en el dispositivo", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Fotos en movimiento", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancelar", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Concede permiso para activar las notificaciones.", "notification_permission_list_tile_enable_button": "Activar notificaciones", "notification_permission_list_tile_title": "Permisos de notificación", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Agregar compañero", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ya no podrá acceder a tus fotos", "partner_page_stop_sharing_title": "¿Dejar de compartir tus fotos?", "partner_page_title": "Compañero", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Volver", "permission_onboarding_continue_anyway": "Continuar de todos modos", "permission_onboarding_get_started": "Empezar", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "¡Permiso concedido! Todo listo.", "permission_onboarding_permission_limited": "Permiso limitado. Para permitir que Immich haga copia de seguridad y gestione toda tu colección de galería, concede permisos de fotos y videos en Configuración.", "permission_onboarding_request": "Immich requiere permiso para ver tus fotos y videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Registros", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Configuración", "profile_drawer_sign_out": "Cerrar sesión", "profile_drawer_trash": "Papelera", + "recently_added": "Recently added", "recently_added_page_title": "Recién Agregados", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Busca tus fotos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -414,7 +443,7 @@ "search_filter_people_title": "Select people", "search_page_categories": "Categorías", "search_page_favorites": "Favoritos", - "search_page_motion_photos": "Fotos en movimiento", + "search_page_motion_photos": "Fotos en .ovimiento", "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", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Administrar enlaces compartidos", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Hecho", + "shared_with_me": "Shared with me", "share_invite": "Invitar al álbum", "sharing_page_album": "Álbumes compartidos", "sharing_page_description": "Crea álbumes compartidos para compartir fotos y videos con personas de tu red.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "La carga en tres etapas puede aumentar el rendimiento de carga pero provoca un consumo de red significativamente mayor", "theme_setting_three_stage_loading_title": "Activar carga en tres etapas", "translated_text_options": "Opciones", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Eliminar", "trash_page_delete_all": "Eliminar todos", @@ -586,7 +618,8 @@ "version_announcement_overlay_text_2": "por favor, tómate tu tiempo para visitar las ", "version_announcement_overlay_text_3": " y asegúrate de que la configuración de docker-compose y .env estén actualizadas para evitar cualquier error de configuración, especialmente si utilizas WatchTower o cualquier mecanismo que actualice automáticamente la aplicación del servidor.", "version_announcement_overlay_title": "Nueva versión del servidor disponible \uD83C\uDF89", + "videos": "Videos", "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/fi-FI.json b/mobile/assets/i18n/fi-FI.json index cb687ecef5f242..4f10b4c78b550b 100644 --- a/mobile/assets/i18n/fi-FI.json +++ b/mobile/assets/i18n/fi-FI.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Päivitä", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Lisätty albumiin {album}", "add_to_album_bottom_sheet_already_exists": "Kohde on jo albumissa {album}", "advanced_settings_log_level_title": "Lokitaso: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Vianetsintä", "album_info_card_backup_album_excluded": "JÄTETTY POIS", "album_info_card_backup_album_included": "SISÄLLYTETTY", + "albums": "Albums", "album_thumbnail_card_item": "1 kohde", "album_thumbnail_card_items": "{} kohdetta", "album_thumbnail_card_shared": "Jaettu", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Poista albumista", "album_viewer_appbar_share_to": "Jaa", "album_viewer_page_share_add_users": "Lisää käyttäjiä", + "all": "All", "all_people_page_title": "Ihmiset", "all_videos_page_title": "Videot", "app_bar_signout_dialog_content": "Haluatko varmasti kirjautua ulos?", "app_bar_signout_dialog_ok": "Kyllä", "app_bar_signout_dialog_title": "Kirjaudu ulos", + "archived": "Archived", "archive_page_no_archived_assets": "Arkistoituja kohteita ei löytynyt", "archive_page_title": "Arkisto ({})", "asset_action_delete_err_read_only": "Vain luku-tilassa olevia kohteita ei voitu poistaa, ohitetaan", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Palauta arkistosta", "control_bottom_app_bar_unfavorite": "Poista suosikeista", "control_bottom_app_bar_upload": "Siirrä palvelimelle", + "create_album": "Create album", "create_album_page_untitled": "Nimetön", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Luo", "create_shared_album_page_share": "Jaa", "create_shared_album_page_share_add_assets": "LISÄÄ KOHTEITA", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Poista jaettu linkki", "description_input_hint_text": "Lisää kuvaus...", "description_input_submit_error": "Virhe kuvauksen päivittämisessä, tarkista lisätiedot lokista", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Päivämäärä ja aika", "edit_date_time_dialog_timezone": "Aikavyöhyke", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Ota käyttöön kokeellinen kuvaruudukko", "experimental_settings_subtitle": "Käyttö omalla vastuulla!", "experimental_settings_title": "Kokeellinen", + "favorites": "Favorites", "favorites_page_no_favorites": "Suosikkikohteita ei löytynyt", "favorites_page_title": "Suosikit", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Ota haptinen palaute käyttöön", "haptic_feedback_title": "Haptinen palaute", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Jos käytät sovellusta ensimmäistä kertaa, muista valita varmuuskopioitavat albumi(t), jotta aikajanalla voi olla kuvia ja videoita.", "home_page_share_err_local": "Paikallisia kohteita ei voitu jakaa linkkien avulla. Hypätään yli", "home_page_upload_err_limit": "Voit lähettää palvelimelle enintään 30 kohdetta kerrallaan, ohitetaan", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Lataus epäonnistui", "image_viewer_page_state_provider_download_started": "Lataaminen aloitettu", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Jakovirhe", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albumit", "library_page_archive": "Arkisto", "library_page_device_albums": "Laitteen albumit", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Liikekuvat", "multiselect_grid_edit_date_time_err_read_only": "Vain luku -tilassa olevien kohteiden päivämäärää ei voitu muokata, ohitetaan", "multiselect_grid_edit_gps_err_read_only": "Vain luku-tilassa olevien kohteiden sijantitietoja ei voitu muokata, ohitetaan", + "my_albums": "My albums", "no_assets_to_show": "Ei näytettäviä kohteita", "no_name": "No name", "notification_permission_dialog_cancel": "Peruuta", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Myönnä käyttöoikeus ottaaksesi ilmoitukset käyttöön.", "notification_permission_list_tile_enable_button": "Ota ilmoitukset käyttöön", "notification_permission_list_tile_title": "Ilmoitusten käyttöoikeus", + "on_this_device": "On this device", "partner_list_user_photos": "Käyttäjän {user} kuvat", "partner_list_view_all": "Näytä kaikki", "partner_page_add_partner": "Lisää kumppani", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ei voi enää käyttää kuviasi.", "partner_page_stop_sharing_title": "Lopetetaanko kuvien jakaminen?", "partner_page_title": "Kumppani", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Takaisin", "permission_onboarding_continue_anyway": "Jatka silti", "permission_onboarding_get_started": "Aloittaminen", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Käyttöoikeus myönnetty! Kaikki valmista.", "permission_onboarding_permission_limited": "Rajoitettu käyttöoikeus. Salliaksesi Immichin varmuuskopioida ja hallita koko kuvakirjastoasi, myönnä oikeus kuviin ja videoihin asetuksista.", "permission_onboarding_request": "Immich vaatii käyttöoikeuden kuvien ja videoiden käyttämiseen.", + "places": "Places", "preferences_settings_title": "Asetukset", "profile_drawer_app_logs": "Lokit", "profile_drawer_client_out_of_date_major": "Sovelluksen mobiiliversio on vanhentunut. Päivitä viimeisimpään merkittävään versioon.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Asetukset", "profile_drawer_sign_out": "Kirjaudu ulos", "profile_drawer_trash": "Roskakori", + "recently_added": "Recently added", "recently_added_page_title": "Viimeksi lisätyt", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Tapahtui virhe", + "search_albums": "Search albums", "search_bar_hint": "Etsi kuvia", "search_filter_apply": "Käytä", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Lähetä", "shared_link_manage_links": "Hallitse jaettuja linkkejä", "shared_link_public_album": "Julkinen albumi", + "shared_links": "Shared links", "share_done": "Valmis", + "shared_with_me": "Shared with me", "share_invite": "Kutsu albumiin", "sharing_page_album": "Jaetut albumit", "sharing_page_description": "Luo jaettuja albumeja jakaaksesi kuvia ja videoita läheisillesi.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Kolmivaiheinen lataaminen saattaa parantaa latauksen suorituskykyä, mutta lisää kaistankäyttöä huomattavasti.", "theme_setting_three_stage_loading_title": "Ota kolmivaiheinen lataus käyttöön", "translated_text_options": "Vaihtoehdot", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Poista", "trash_page_delete_all": "Poista kaikki", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "Ota hetki aikaa vieraillaksesi", "version_announcement_overlay_text_3": "ja varmista, että käyttämäsi docker-compose ja .env-asetukset ovat ajantasalla välttyäksesi asetusongelmilta. Varsinkin jos käytät WatchToweria tai jotain muuta mekanismia päivittääksesi palvelinsovellusta automaattisesti.", "version_announcement_overlay_title": "Uusi palvelinversio saatavilla \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Poista pinosta", "viewer_stack_use_as_main_asset": "Käytä pääkohteena", "viewer_unstack": "Pura pino" diff --git a/mobile/assets/i18n/fr-CA.json b/mobile/assets/i18n/fr-CA.json index 8d742c3a5943e5..9e51cc7cbf5f64 100644 --- a/mobile/assets/i18n/fr-CA.json +++ b/mobile/assets/i18n/fr-CA.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Ajouté à {album}", "add_to_album_bottom_sheet_already_exists": "Déjà dans {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Dépannage", "album_info_card_backup_album_excluded": "EXCLUS", "album_info_card_backup_album_included": "INCLUS", + "albums": "Albums", "album_thumbnail_card_item": "1 élément", "album_thumbnail_card_items": "{} éléments", "album_thumbnail_card_shared": " · Partagé", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Retirer de l'album", "album_viewer_appbar_share_to": "Partager à", "album_viewer_page_share_add_users": "Ajouter des utilisateurs", + "all": "All", "all_people_page_title": "Personnes", "all_videos_page_title": "Vidéos", "app_bar_signout_dialog_content": "Êtes-vous sûr de vouloir vous déconnecter?", "app_bar_signout_dialog_ok": "Oui", "app_bar_signout_dialog_title": "Se déconnecter", + "archived": "Archived", "archive_page_no_archived_assets": "Aucun élément archivé n'a été trouvé", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Désarchiver", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Téléverser", + "create_album": "Create album", "create_album_page_untitled": "Sans titre", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Créer", "create_shared_album_page_share": "Partager", "create_shared_album_page_share_add_assets": "AJOUTER DES ÉLÉMENTS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Supprimer le lien partagé", "description_input_hint_text": "Ajouter une description...", "description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale", "experimental_settings_subtitle": "Utilisez à vos dépends!", "experimental_settings_title": "Expérimental", + "favorites": "Favorites", "favorites_page_no_favorites": "Aucun élément favori n'a été trouvé", "favorites_page_title": "Favoris", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Si c'est la première fois que vous utilisez l'application, veillez à choisir un ou plusieurs albums de sauvegarde afin que la chronologie puisse alimenter les photos et les vidéos de cet ou ces albums.", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Limite de téléchargement de 30 éléments en même temps, demande ignorée", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Erreur de téléchargement", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Erreur de partage", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums sur l'appareil", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Photos avec mouvement", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Annuler", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Accordez la permission d'activer les notifications.", "notification_permission_list_tile_enable_button": "Activer les notifications", "notification_permission_list_tile_title": "Permission de notification", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Ajouter un partenaire", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ne pourra plus accéder à vos photos.", "partner_page_stop_sharing_title": "Arrêter de partager vos photos?", "partner_page_title": "Partenaire", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Retour", "permission_onboarding_continue_anyway": "Continuer quand même", "permission_onboarding_get_started": "Commencer", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission accordée! Vous êtes prêts.", "permission_onboarding_permission_limited": "Permission limitée. Pour permettre à Immich de sauvegarder et de gérer l'ensemble de votre bibliothèque, accordez l'autorisation pour les photos et vidéos dans les Paramètres.", "permission_onboarding_request": "Immich demande l'autorisation de visionner vos photos et vidéo", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Journaux", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Paramètres", "profile_drawer_sign_out": "Se déconnecter", "profile_drawer_trash": "Corbeille", + "recently_added": "Recently added", "recently_added_page_title": "Récemment ajouté", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Rechercher vos photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Gérer les liens partagés", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Fait", + "shared_with_me": "Shared with me", "share_invite": "Inviter à l'album", "sharing_page_album": "Albums partagés", "sharing_page_description": "Créez des albums partagés pour partager des photos et des vidéos avec les personnes de votre réseau.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Le chargement en trois étapes peut améliorer les performances de chargement, mais entraîne une augmentation significative de la charge du réseau.", "theme_setting_three_stage_loading_title": "Activer le chargement en trois étapes", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Supprimer", "trash_page_delete_all": "Tout supprimer", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "veuillez prendre le temps de visiter le ", "version_announcement_overlay_text_3": " et assurez-vous que votre configuration docker-compose et .env est à jour pour éviter toute erreur de configuration, en particulier si vous utilisez WatchTower ou tout autre mécanisme qui gère la mise à jour automatique de votre application serveur.", "version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Retirer de la pile", "viewer_stack_use_as_main_asset": "Utiliser comme élément principal", "viewer_unstack": "Désempiler" diff --git a/mobile/assets/i18n/fr-FR.json b/mobile/assets/i18n/fr-FR.json index 9ff5c6f28031e4..2293d9ca304bb8 100644 --- a/mobile/assets/i18n/fr-FR.json +++ b/mobile/assets/i18n/fr-FR.json @@ -6,6 +6,7 @@ "action_common_save": "Sauvegarder", "action_common_select": "Sélectionner", "action_common_update": "Mise à jour", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Ajouté à {album}", "add_to_album_bottom_sheet_already_exists": "Déjà dans {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Dépannage", "album_info_card_backup_album_excluded": "EXCLU", "album_info_card_backup_album_included": "INCLUS", + "albums": "Albums", "album_thumbnail_card_item": "1 élément", "album_thumbnail_card_items": "{} éléments", "album_thumbnail_card_shared": " · Partagé", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Retirer de l'album", "album_viewer_appbar_share_to": "Partager à", "album_viewer_page_share_add_users": "Ajouter des utilisateurs", + "all": "All", "all_people_page_title": "Personnes", "all_videos_page_title": "Vidéos", "app_bar_signout_dialog_content": "Êtes-vous sûr de vouloir vous déconnecter ?", "app_bar_signout_dialog_ok": "Oui", "app_bar_signout_dialog_title": "Se déconnecter", + "archived": "Archived", "archive_page_no_archived_assets": "Aucun élément archivé n'a été trouvé", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Impossible de supprimer le(s) élément(s) en lecture seule.", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Désarchiver", "control_bottom_app_bar_unfavorite": "Enlever des favoris", "control_bottom_app_bar_upload": "Téléverser", + "create_album": "Create album", "create_album_page_untitled": "Sans titre", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Créer", "create_shared_album_page_share": "Partager", "create_shared_album_page_share_add_assets": "AJOUTER DES ÉLÉMENTS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Supprimer le lien partagé", "description_input_hint_text": "Ajouter une description…", "description_input_submit_error": "Erreur de mise à jour de la description, vérifier le journal pour plus de détails", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date et heure", "edit_date_time_dialog_timezone": "Fuseau horaire", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Activer la grille de photos expérimentale", "experimental_settings_subtitle": "Utilisez à vos dépends !", "experimental_settings_title": "Expérimental", + "favorites": "Favorites", "favorites_page_no_favorites": "Aucun élément favori n'a été trouvé", "favorites_page_title": "Favoris", "filename_search": "Nom de fichier ou extension", + "filter": "Filter", "haptic_feedback_switch": "Activer le retour haptique", "haptic_feedback_title": "Retour haptique", "header_settings_add_header_tip": "Ajouter un en-tête", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Si c'est la première fois que vous utilisez l'application, veillez à choisir un ou plusieurs albums de sauvegarde afin que la chronologie puisse alimenter les photos et les vidéos de cet ou ces albums.", "home_page_share_err_local": "Impossible de partager par lien les médias locaux, cette opération est donc ignorée.", "home_page_upload_err_limit": "Limite de téléchargement de 30 éléments en même temps, demande ignorée", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Erreur de téléchargement", "image_viewer_page_state_provider_download_started": "Téléchargement démarré", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Erreur de partage", "invalid_date": "Date invalide", "invalid_date_format": "Format de date invalide", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums sur l'appareil", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Photos avec mouvement", "multiselect_grid_edit_date_time_err_read_only": "Impossible de modifier la date d'un élément d'actif en lecture seule.", "multiselect_grid_edit_gps_err_read_only": "Impossible de modifier l'emplacement d'un élément en lecture seule.", + "my_albums": "My albums", "no_assets_to_show": "Aucuns éléments à afficher", "no_name": "Sans nom", "notification_permission_dialog_cancel": "Annuler", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Accordez la permission d'activer les notifications.", "notification_permission_list_tile_enable_button": "Activer les notifications", "notification_permission_list_tile_title": "Permission de notification", + "on_this_device": "On this device", "partner_list_user_photos": "Photos de {user}", "partner_list_view_all": "Voir tous", "partner_page_add_partner": "Ajouter un partenaire", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ne pourra plus accéder à vos photos.", "partner_page_stop_sharing_title": "Arrêter de partager vos photos ?", "partner_page_title": "Partenaire", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Retour", "permission_onboarding_continue_anyway": "Continuer quand même", "permission_onboarding_get_started": "Commencer", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission accordée ! Vous êtes prêts.", "permission_onboarding_permission_limited": "Permission limitée. Pour permettre à Immich de sauvegarder et de gérer l'ensemble de votre bibliothèque, accordez l'autorisation pour les photos et vidéos dans les Paramètres.", "permission_onboarding_request": "Immich demande l'autorisation de visionner vos photos et vidéo", + "places": "Places", "preferences_settings_title": "Préférences", "profile_drawer_app_logs": "Journaux", "profile_drawer_client_out_of_date_major": "L'application mobile est obsolète. Veuillez effectuer la mise à jour vers la dernière version majeure.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Paramètres", "profile_drawer_sign_out": "Se déconnecter", "profile_drawer_trash": "Corbeille", + "recently_added": "Recently added", "recently_added_page_title": "Récemment ajouté", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Une erreur s'est produite", + "search_albums": "Search albums", "search_bar_hint": "Rechercher vos photos", "search_filter_apply": "Appliquer le filtre", "search_filter_camera": "Appareil", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Chargement", "shared_link_manage_links": "Gérer les liens partagés", "shared_link_public_album": "Album public", + "shared_links": "Shared links", "share_done": "Fait", + "shared_with_me": "Shared with me", "share_invite": "Inviter à l'album", "sharing_page_album": "Albums partagés", "sharing_page_description": "Créez des albums partagés pour partager des photos et des vidéos avec les personnes de votre réseau.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Le chargement en trois étapes peut améliorer les performances de chargement, mais entraîne une augmentation significative de la charge du réseau.", "theme_setting_three_stage_loading_title": "Activer le chargement en trois étapes", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Corbeille vidée", "trash_page_delete": "Supprimer", "trash_page_delete_all": "Tout supprimer", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "veuillez prendre le temps de visiter le ", "version_announcement_overlay_text_3": " et assurez-vous que votre configuration docker-compose et .env est à jour pour éviter toute erreur de configuration, en particulier si vous utilisez WatchTower ou tout autre mécanisme qui gère la mise à jour automatique de votre application serveur.", "version_announcement_overlay_title": "Nouvelle version serveur disponible \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Retirer de la pile", "viewer_stack_use_as_main_asset": "Utiliser comme élément principal", "viewer_unstack": "Désempiler" diff --git a/mobile/assets/i18n/he-IL.json b/mobile/assets/i18n/he-IL.json index 7ddbb392a0cf60..a7b14d2b74dd9c 100644 --- a/mobile/assets/i18n/he-IL.json +++ b/mobile/assets/i18n/he-IL.json @@ -6,6 +6,7 @@ "action_common_save": "שמור", "action_common_select": "בחר", "action_common_update": "עדכון", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "נוסף ל {album}", "add_to_album_bottom_sheet_already_exists": "כבר ב {album}", "advanced_settings_log_level_title": "רמת תיעוד אירועים: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "פתרון בעיות", "album_info_card_backup_album_excluded": "הוחרגו", "album_info_card_backup_album_included": "נכללו", + "albums": "Albums", "album_thumbnail_card_item": "פריט 1", "album_thumbnail_card_items": "{} פריטים", "album_thumbnail_card_shared": " · משותף", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "הסרה מאלבום", "album_viewer_appbar_share_to": "שתף עם", "album_viewer_page_share_add_users": "הוסף משתמשים", + "all": "All", "all_people_page_title": "אנשים", "all_videos_page_title": "סרטונים", "app_bar_signout_dialog_content": "האם את/ה בטוח/ה שברצונך להתנתק?", "app_bar_signout_dialog_ok": "כן", "app_bar_signout_dialog_title": "התנתק", + "archived": "Archived", "archive_page_no_archived_assets": "לא נמצאו נכסים בארכיון", "archive_page_title": "ארכיון ({})", "asset_action_delete_err_read_only": "לא ניתן למחוק נכס(ים) לקריאה בלבד, מדלג", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "הוצא מארכיון", "control_bottom_app_bar_unfavorite": "הסר ממועדפים", "control_bottom_app_bar_upload": "העלאה", + "create_album": "Create album", "create_album_page_untitled": "ללא כותרת", + "create_new": "CREATE NEW", "create_shared_album_page_create": "יצירה", "create_shared_album_page_share": "שתף", "create_shared_album_page_share_add_assets": "הוסף נכסים", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "מחק קישור משותף", "description_input_hint_text": "הוסף תיאור...", "description_input_submit_error": "שגיאה בעדכון תיאור, בדוק את היומן לפרטים נוספים", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "הורדה בוטלה", + "download_complete": "הורדה הושלמה", + "download_enqueue": "הורדה נוספה לתור", + "download_error": "שגיאת הורדה", + "download_failed": "הורדה נכשלה", + "download_filename": "קובץ: {}", + "download_finished": "הורדה הסתיימה", + "downloading": "מוריד...", + "downloading_media": "מוריד מדיה", + "download_notfound": "הורדה לא נמצא", + "download_paused": "הורדה הופסקה", + "download_started": "הורדה החלה", + "download_sucess": "הצלחת הורדה", + "download_sucess_android": "המדיה הורדה אל DCIM/Immich", + "download_waiting_to_retry": "מחכה כדי לנסות שוב", "edit_date_time_dialog_date_time": "תאריך וזמן", "edit_date_time_dialog_timezone": "אזור זמן", "edit_image_title": "ערוך", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "אפשר רשת תמונות ניסיונית", "experimental_settings_subtitle": "השימוש הוא על אחריותך בלבד!", "experimental_settings_title": "נסיוני", + "favorites": "Favorites", "favorites_page_no_favorites": "לא נמצאו נכסים מועדפים", "favorites_page_title": "מועדפים", "filename_search": "שם קובץ או סיומת", + "filter": "Filter", "haptic_feedback_switch": "אפשר משוב ברטט", "haptic_feedback_title": "משוב ברטט", "header_settings_add_header_tip": "הוסף כותרת", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "אם זאת הפעם הראשונה שאת/ה משתמש/ת ביישום, נא להקפיד לבחור אלבומ(ים) לגיבוי כך שציר הזמן יוכל לאכלס תמונות וסרטונים באלבומ(ים)", "home_page_share_err_local": "לא ניתן לשתף נכסים מקומיים על ידי קישור, מדלג", "home_page_upload_err_limit": "ניתן להעלות רק מקסימום של 30 נכסים בכל פעם, מדלג", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "תמונה נשמרה", "image_viewer_page_state_provider_download_error": "שגיאת הורדה", "image_viewer_page_state_provider_download_started": "ההורדה החלה", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "שיתוף שגיאה", "invalid_date": "תאריך לא תקין", "invalid_date_format": "פורמט תאריך לא תקין", + "library": "Library", "library_page_albums": "אלבומים", "library_page_archive": "ארכיון", "library_page_device_albums": "אלבומים במכשיר", @@ -342,6 +364,7 @@ "motion_photos_page_title": "תמונות עם תנועה", "multiselect_grid_edit_date_time_err_read_only": "לא ניתן לערוך תאריך של נכס(ים) לקריאה בלבד, מדלג", "multiselect_grid_edit_gps_err_read_only": "לא ניתן לערוך מיקום של נכס(ים) לקריאה בלבד, מדלג", + "my_albums": "My albums", "no_assets_to_show": "אין נכסים להציג", "no_name": "ללא שם", "notification_permission_dialog_cancel": "ביטול", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "הענק הרשאה כדי לאפשר התראות", "notification_permission_list_tile_enable_button": "אפשר התראות", "notification_permission_list_tile_title": "הרשאת התראה", + "on_this_device": "On this device", "partner_list_user_photos": "תמונות של {user}", "partner_list_view_all": "הצג הכל", "partner_page_add_partner": "הוספת שותף", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} לא יוכל יותר לגשת לתמונות שלך", "partner_page_stop_sharing_title": "להפסיק לשתף את התמונות שלך?", "partner_page_title": "שותף", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "חזרה", "permission_onboarding_continue_anyway": "המשך בכל זאת", "permission_onboarding_get_started": "להתחיל", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "ההרשאה ניתנה! את/ה מוכנ/ה", "permission_onboarding_permission_limited": "הרשאה מוגבלת. כדי לתת ליישום לגבות ולנהל את כל אוסף הגלריה שלך, הענק הרשאה לתמונות וסרטונים בהגדרות", "permission_onboarding_request": "היישום דורש הרשאה כדי לראות את התמונות והסרטונים שלך", + "places": "Places", "preferences_settings_title": "העדפות", "profile_drawer_app_logs": "יומן", "profile_drawer_client_out_of_date_major": "האפליקציה לנייד היא מיושנת. נא לעדכן לגרסה הראשית האחרונה", @@ -383,9 +410,11 @@ "profile_drawer_settings": "הגדרות", "profile_drawer_sign_out": "יציאה", "profile_drawer_trash": "אשפה", + "recently_added": "Recently added", "recently_added_page_title": "נוסף לאחרונה", "save_to_gallery": "שמור לגלריה", "scaffold_body_error_occurred": "אירעה שגיאה", + "search_albums": "Search albums", "search_bar_hint": "חפש/י בתמונות שלך", "search_filter_apply": "החל סינון", "search_filter_camera": "מצלמה", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "העלאה", "shared_link_manage_links": "ניהול קישורים משותפים", "shared_link_public_album": "אלבום ציבורי", + "shared_links": "Shared links", "share_done": "סיום", + "shared_with_me": "Shared with me", "share_invite": "הזמן לאלבום", "sharing_page_album": "אלבומים משותפים", "sharing_page_description": "צור אלבומים משותפים כדי לשתף תמונות וסרטונים עם אנשים ברשת שלך", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "טעינה בשלושה שלבים עשויה לשפר את ביצועי הטעינה אבל גורמת באופן משמעותי לעומס רשת גבוה יותר", "theme_setting_three_stage_loading_title": "אפשר טעינה בשלושה שלבים", "translated_text_options": "אפשרויות", + "trash": "Trash", "trash_emptied": "האשפה רוקנה", "trash_page_delete": "מחק", "trash_page_delete_all": "מחק הכל", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "אנא קח/י את הזמן שלך לבקר ב ", "version_announcement_overlay_text_3": " ולוודא שמבנה ה docker-compose וה env. שלך עדכני כדי למנוע תצורות שגויות, במיוחד אם את/ה משתמש/ת ב WatchTower או בכל מנגנון שמטפל בעדכון יישום השרת שלך באופן אוטומטי", "version_announcement_overlay_title": "גרסת שרת חדשה זמינה \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "הסר מערימה", "viewer_stack_use_as_main_asset": "השתמש כנכס ראשי", "viewer_unstack": "ביטול ערימה" diff --git a/mobile/assets/i18n/hi-IN.json b/mobile/assets/i18n/hi-IN.json index 534cae0622d9d5..104dae2ebd95e1 100644 --- a/mobile/assets/i18n/hi-IN.json +++ b/mobile/assets/i18n/hi-IN.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "साझा करें", "album_viewer_page_share_add_users": "Add users", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "क्या आप सुनिश्चित हैं कि आप लॉग आउट करना चाहते हैं?", "app_bar_signout_dialog_ok": "हाँ", "app_bar_signout_dialog_title": "लॉग आउट", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "साझा किए गए लिंक को हटाएं", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "लोकल एसेट्स को लिंक के जरिए शेयर नहीं कर सकते, स्किप कर रहे हैं", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "वापस", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "साझा किए गए लिंक का प्रबंधन करें", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "स्टैक से हटाएं", "viewer_stack_use_as_main_asset": "मुख्य संपत्ति के रूप में उपयोग करें", "viewer_unstack": "स्टैक रद्द करें" diff --git a/mobile/assets/i18n/hu-HU.json b/mobile/assets/i18n/hu-HU.json index 8f14b9673a578a..e28535f9b15f1a 100644 --- a/mobile/assets/i18n/hu-HU.json +++ b/mobile/assets/i18n/hu-HU.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Frissít", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Hozzáadva a(z) \"{album}\" albumhoz", "add_to_album_bottom_sheet_already_exists": "Már benne van a(z) \"{album}\" albumban", "advanced_settings_log_level_title": "Naplózás szintje: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Hibaelhárítás", "album_info_card_backup_album_excluded": "KIHAGYVA", "album_info_card_backup_album_included": "BELEÉRTVE", + "albums": "Albums", "album_thumbnail_card_item": "1 elem", "album_thumbnail_card_items": "{} elem", "album_thumbnail_card_shared": "· Megosztott", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Eltávolítás az albumból", "album_viewer_appbar_share_to": "Megosztás Ide", "album_viewer_page_share_add_users": "Felhasználók hozzáadása", + "all": "All", "all_people_page_title": "Emberek", "all_videos_page_title": "Videók", "app_bar_signout_dialog_content": "Biztos, hogy ki szeretnél jelentkezni?", "app_bar_signout_dialog_ok": "Igen", "app_bar_signout_dialog_title": "Kijelentkezés", + "archived": "Archived", "archive_page_no_archived_assets": "Nem található archivált elem", "archive_page_title": "Archívum ({})", "asset_action_delete_err_read_only": "Csak-olvasható elem(ek)et nem lehet törölni, így ezeket átugorjuk", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Nem Archivált", "control_bottom_app_bar_unfavorite": "Nem Kedvenc", "control_bottom_app_bar_upload": "Feltöltés", + "create_album": "Create album", "create_album_page_untitled": "Névtelen", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Létrehoz", "create_shared_album_page_share": "Megosztás", "create_shared_album_page_share_add_assets": "ELEMEK HOZZÁADÁSA", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Megosztott Link Törlése", "description_input_hint_text": "Leírás hozzáadása...", "description_input_submit_error": "Nem sikerült frissíteni a leírást. További információért kérjük, nézd meg az eseménynaplót", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Dátum és Idő", "edit_date_time_dialog_timezone": "Időzóna", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Kisérleti képrács engedélyezése", "experimental_settings_subtitle": "Csak saját felelősségre használd!", "experimental_settings_title": "Kísérleti", + "favorites": "Favorites", "favorites_page_no_favorites": "Nem található kedvencnek jelölt elem", "favorites_page_title": "Kedvencek", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Rezgéses visszajelzés engedélyezése", "haptic_feedback_title": "Rezgéses Visszajelzés", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Ha most használod először az alkalmazást, akkor ahhoz, hogy megjelenjenek a fotók és a videók az idővonaladon, állítsd be, hogy melyik albumaidról készüljön biztonsági mentés.", "home_page_share_err_local": "Helyi elemekről nem lehet megosztási linket készíteni, úgyhogy kihagyjuk", "home_page_upload_err_limit": "Csak 30 elemet tudsz egyszerre feltölteni, úgyhogy kihagyjuk", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Letöltési Hiba", "image_viewer_page_state_provider_download_started": "Letöltés Megkezdődött", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Megosztási Hiba", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albumok", "library_page_archive": "Archívum", "library_page_device_albums": "Albumok az Eszközön", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Mozgó Fotók", "multiselect_grid_edit_date_time_err_read_only": "Csak-olvasható elem(ek) dátuma nem módosítható, ezért kihagyjuk", "multiselect_grid_edit_gps_err_read_only": "Csak-olvasható elem(ek) helyszíne nem módosítható, ezért kihagyjuk", + "my_albums": "My albums", "no_assets_to_show": "Nincs megjeleníthető elem", "no_name": "No name", "notification_permission_dialog_cancel": "Mégsem", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Értesítések engedélyezése", "notification_permission_list_tile_enable_button": "Értesítések Bekapcsolása", "notification_permission_list_tile_title": "Engedély az Értesítésekhez", + "on_this_device": "On this device", "partner_list_user_photos": "{user} fényképei", "partner_list_view_all": "Összes mutatása", "partner_page_add_partner": "Partner hozzáadása", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} nem fog többé hozzáférni a fotóidhoz.", "partner_page_stop_sharing_title": "Fotók megosztásának megszűntetése?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Vissza", "permission_onboarding_continue_anyway": "Folytatás mindenképp", "permission_onboarding_get_started": "Kezdjük el", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Hozzáférés engedélyezve! Minden készen áll.", "permission_onboarding_permission_limited": "Korlátozott hozzáférés. Ha szeretnéd, hogy az Immich a teljes galéria gyűjteményedet mentse és kezelje, akkor a Beállításokban engedélyezd a fotó és videó jogosultságokat.", "permission_onboarding_request": "Engedélyezni kell, hogy az Immich hozzáférjen a képekhez és videókhoz", + "places": "Places", "preferences_settings_title": "Beállítások", "profile_drawer_app_logs": "Naplók", "profile_drawer_client_out_of_date_major": "A mobilalkalmazás elavult. Kérjük, frissítsd a legfrisebb főverzióra.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Beállítások", "profile_drawer_sign_out": "Kijelentkezés", "profile_drawer_trash": "Lomtár", + "recently_added": "Recently added", "recently_added_page_title": "Nemrég Hozzáadott", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Hiba történt", + "search_albums": "Search albums", "search_bar_hint": "Fotók keresése", "search_filter_apply": "Szűrő alkalmazása", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Feltöltés", "shared_link_manage_links": "Megosztási linkek kezelése", "shared_link_public_album": "Nyilvános album", + "shared_links": "Shared links", "share_done": "Kész", + "shared_with_me": "Shared with me", "share_invite": "Meghívás az albumba", "sharing_page_album": "Megosztott albumok", "sharing_page_description": "Megosztott albumok létrehozásával fényképeket és videókat oszthatsz meg a hálózatodban lévő emberekkel.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "A háromlépcsős betöltés javíthatja a betöltési teljesítményt, de jelentősen növeli a hálózati forgalmat", "theme_setting_three_stage_loading_title": "Háromlépcsős betöltés engedélyezése", "translated_text_options": "Beállítások", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Töröl", "trash_page_delete_all": "Mindet Töröl", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "kérlek szánj időt arra, hogy ", "version_announcement_overlay_text_3": "és gyöződj meg róla, hogy a docker-compose és .env beállításai naprakészek és pontosak, különösen akkor, ha watchtower-t vagy bármi olyan megoldást használsz, ami automatikusan frissíti a szervert.", "version_announcement_overlay_title": "Új Szerververzió Érhető El \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Eltávolít a Csoportból", "viewer_stack_use_as_main_asset": "Fő Elemnek Beállít", "viewer_unstack": "Csoport Megszűntetése" diff --git a/mobile/assets/i18n/it-IT.json b/mobile/assets/i18n/it-IT.json index d7585c753c99bd..3d5c2805f08201 100644 --- a/mobile/assets/i18n/it-IT.json +++ b/mobile/assets/i18n/it-IT.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Aggiorna", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Aggiunto in {album}", "add_to_album_bottom_sheet_already_exists": "Già presente in {album}", "advanced_settings_log_level_title": "Livello log: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Risoluzione problemi", "album_info_card_backup_album_excluded": "ESCLUSI", "album_info_card_backup_album_included": "INCLUSI", + "albums": "Albums", "album_thumbnail_card_item": "1 elemento ", "album_thumbnail_card_items": "{} elementi", "album_thumbnail_card_shared": "Condiviso", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Rimuovere dall'album ", "album_viewer_appbar_share_to": "Condividi a", "album_viewer_page_share_add_users": "Aggiungi utenti", + "all": "All", "all_people_page_title": "Persone", "all_videos_page_title": "Video", "app_bar_signout_dialog_content": "Sei sicuro di volerti disconnettere?", "app_bar_signout_dialog_ok": "Si", "app_bar_signout_dialog_title": "Disconnetti", + "archived": "Archived", "archive_page_no_archived_assets": "Nessuna oggetto archiviato", "archive_page_title": "Archivia ({})", "asset_action_delete_err_read_only": "Non puoi eliminare risorse in sola lettura, azione ignorata", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Rimuovi dagli archivi", "control_bottom_app_bar_unfavorite": "Rimuovi preferito", "control_bottom_app_bar_upload": "Carica", + "create_album": "Create album", "create_album_page_untitled": "Senza titolo", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Crea", "create_shared_album_page_share": "Condividi", "create_shared_album_page_share_add_assets": "AGGIUNGI OGGETTI", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Elimina link condiviso", "description_input_hint_text": "Aggiungi descrizione...", "description_input_submit_error": "Errore modificare descrizione, controlli I log per maggiori dettagli", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Data e ora", "edit_date_time_dialog_timezone": "Fuso orario", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Attiva griglia foto sperimentale", "experimental_settings_subtitle": "Usalo a tuo rischio!", "experimental_settings_title": "Sperimentale", + "favorites": "Favorites", "favorites_page_no_favorites": "Nessun preferito", "favorites_page_title": "Preferiti", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Abilita feedback aptico", "haptic_feedback_title": "Feedback aptico", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Se è la prima volta che utilizzi l'app, assicurati di scegliere uno o più album di backup, in modo che la timeline possa popolare le foto e i video presenti negli album.", "home_page_share_err_local": "Non puoi condividere una risorsa locale tramite link, azione ignorata", "home_page_upload_err_limit": "Puoi caricare al massimo 30 file per volta, ignora quelli in eccesso", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Errore nel Download", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Errore di condivisione", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Album", "library_page_archive": "Archivia", "library_page_device_albums": "Album sul dispositivo", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Foto in movimento", "multiselect_grid_edit_date_time_err_read_only": "Non puoi modificare la data di risorse in sola lettura, azione ignorata", "multiselect_grid_edit_gps_err_read_only": "Non puoi modificare la posizione di risorse in sola lettura, azione ignorata", + "my_albums": "My albums", "no_assets_to_show": "Nessuna risorsa da mostrare", "no_name": "No name", "notification_permission_dialog_cancel": "Annulla", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Concedi i permessi per attivare le notifiche", "notification_permission_list_tile_enable_button": "Attiva notifiche", "notification_permission_list_tile_title": "Permessi delle Notifiche", + "on_this_device": "On this device", "partner_list_user_photos": "Foto di {user}", "partner_list_view_all": "Mostra tutto", "partner_page_add_partner": "Aggiungi partner.", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} non sarà più in grado di accedere alle tue foto.", "partner_page_stop_sharing_title": "Stoppare la condivisione delle tue foto?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Indietro", "permission_onboarding_continue_anyway": "Continua lo stesso", "permission_onboarding_get_started": "Inizia", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Concessi i permessi! Ora sei tutto apposto", "permission_onboarding_permission_limited": "Permessi limitati. Per consentire a Immich di gestire e fare i backup di tutta la galleria, concedi i permessi Foto e Video dalle Impostazioni.", "permission_onboarding_request": "Immich richiede i permessi per vedere le tue foto e video", + "places": "Places", "preferences_settings_title": "Preferenze", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "L'applicazione non è aggiornata. Per favore aggiorna all'ultima versione principale.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Impostazioni ", "profile_drawer_sign_out": "Esci", "profile_drawer_trash": "Cestino", + "recently_added": "Recently added", "recently_added_page_title": "Aggiunti di recente", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Si è verificato un errore.", + "search_albums": "Search albums", "search_bar_hint": "Cerca le tue foto", "search_filter_apply": "Applica filtro", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Carica", "shared_link_manage_links": "Gestisci link condivisi", "shared_link_public_album": "Album Pubblico", + "shared_links": "Shared links", "share_done": "Fatto", + "shared_with_me": "Shared with me", "share_invite": "Invita nell'album ", "sharing_page_album": "Album condivisi", "sharing_page_description": "Crea un album condiviso per condividere foto e video con gli utenti della tua rete Immich.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Il caricamento a tre stage aumenterà le performance di caricamento ma anche il consumo di banda", "theme_setting_three_stage_loading_title": "Abilita il caricamento a tre stage", "translated_text_options": "Opzioni", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Elimina", "trash_page_delete_all": "Elimina tutti", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "per favore prenditi il tuo tempo per visitare le ", "version_announcement_overlay_text_3": " e verifica che il tuo docker-compose e il file .env siano aggiornati per impedire qualsiasi errore di configurazione, specialmente se utilizzate WatchTower o altri strumenti per l'aggiornamento automatico dell'applicativo", "version_announcement_overlay_title": "Nuova versione del server disponibile \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Rimuovi dalla pila", "viewer_stack_use_as_main_asset": "Usa come risorsa principale", "viewer_unstack": "Rimuovi dal gruppo" diff --git a/mobile/assets/i18n/ja-JP.json b/mobile/assets/i18n/ja-JP.json index 21b8bea9e35e9d..e5fed5705d69aa 100644 --- a/mobile/assets/i18n/ja-JP.json +++ b/mobile/assets/i18n/ja-JP.json @@ -3,16 +3,17 @@ "action_common_cancel": "キャンセル", "action_common_clear": "クリア", "action_common_confirm": "了解", - "action_common_save": "Save", - "action_common_select": "Select", + "action_common_save": "保存", + "action_common_select": "選択", "action_common_update": "更新", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "{album}に追加", "add_to_album_bottom_sheet_already_exists": "{album}に追加済み", "advanced_settings_log_level_title": "ログレベル: {}", "advanced_settings_prefer_remote_subtitle": "デバイスによっては、デバイス上にあるサムネイルのロードに非常に時間がかかることがあります。このオプションをに有効にする事により、サーバーから直接画像をロードすることが可能です。", "advanced_settings_prefer_remote_title": "リモートを優先する", - "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", - "advanced_settings_proxy_headers_title": "Proxy Headers", + "advanced_settings_proxy_headers_subtitle": "プロキシヘッダを設定する", + "advanced_settings_proxy_headers_title": "プロキシヘッダ", "advanced_settings_self_signed_ssl_subtitle": "SSLのチェックをスキップする。自己署名証明書が必要です。", "advanced_settings_self_signed_ssl_title": "自己署名証明書を許可する", "advanced_settings_tile_subtitle": "追加ユーザー設定", @@ -21,12 +22,13 @@ "advanced_settings_troubleshooting_title": "トラブルシューティング", "album_info_card_backup_album_excluded": "除外中", "album_info_card_backup_album_included": "選択中", + "albums": "Albums", "album_thumbnail_card_item": "1枚", "album_thumbnail_card_items": "{}枚", "album_thumbnail_card_shared": "共有済み", "album_thumbnail_owned": "所有中", "album_thumbnail_shared_by": "{}が共有中", - "album_viewer_appbar_delete_confirm": "本当にこのアルバムをアカウントから削除しますか?", + "album_viewer_appbar_delete_confirm": "本当にこのアルバムを削除しますか?", "album_viewer_appbar_share_delete": "アルバムを削除", "album_viewer_appbar_share_err_delete": "削除失敗", "album_viewer_appbar_share_err_leave": "退出失敗", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "アルバムから削除", "album_viewer_appbar_share_to": "次の方々と共有します", "album_viewer_page_share_add_users": "ユーザーを追加", - "all_people_page_title": "ピープル", + "all": "All", + "all_people_page_title": "人物", "all_videos_page_title": "ビデオ", "app_bar_signout_dialog_content": " サインアウトしますか?", "app_bar_signout_dialog_ok": "はい", "app_bar_signout_dialog_title": " サインアウト", + "archived": "Archived", "archive_page_no_archived_assets": "アーカイブ済みの写真またはビデオがありません", "archive_page_title": "アーカイブ({})", "asset_action_delete_err_read_only": "読み取り専用の項目は削除できません。スキップします", @@ -54,13 +58,13 @@ "asset_list_layout_sub_title": "レイアウト", "asset_list_settings_subtitle": "グリッドに関する設定", "asset_list_settings_title": "グリッド", - "asset_restored_successfully": "Asset restored successfully", - "assets_deleted_permanently": "{} asset(s) deleted permanently", - "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", - "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", - "assets_restored_successfully": "{} asset(s) restored successfully", - "assets_trashed": "{} asset(s) trashed", - "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_restored_successfully": "{}項目を復元しました", + "assets_deleted_permanently": "{}項目を完全に削除しました", + "assets_deleted_permanently_from_server": "サーバー上の{}項目を完全に削除しました", + "assets_removed_permanently_from_device": "端末から{}項目を完全に削除しました", + "assets_restored_successfully": "{}項目を復元しました", + "assets_trashed": "{}項目をゴミ箱に移動しました", + "assets_trashed_from_server": "サーバー上の{}項目をゴミ箱に移動しました", "asset_viewer_settings_title": "アセットビューアー", "backup_album_selection_page_albums_device": "端末上のアルバム数: {} ", "backup_album_selection_page_albums_tap": "タップで選択、ダブルタップで除外", @@ -81,7 +85,7 @@ "backup_controller_page_background_app_refresh_disabled_title": "バックグラウンド更新はオフになっています", "backup_controller_page_background_app_refresh_enable_button_text": "設定を開く", "backup_controller_page_background_battery_info_link": "詳細", - "backup_controller_page_background_battery_info_message": "バックグラウンド処理を正常に動作させるためには、Immichに適用されているバッテリーの最適化や自動調整をオフにしてください。\n\nデバイスによって変更方法が異なります。", + "backup_controller_page_background_battery_info_message": "バックグラウンド処理を正常に動作させるためには、Immichアプリに適用されているバッテリーの最適化をオフにしてください。\n\nデバイスによって設定方法が異なりますので各々調べてください", "backup_controller_page_background_battery_info_ok": "了解", "backup_controller_page_background_battery_info_title": "バッテリーの最適化", "backup_controller_page_background_charging": "充電中のみ", @@ -112,7 +116,7 @@ "backup_controller_page_start_backup": "バックアップ開始", "backup_controller_page_status_off": "バックアップがオフになっています", "backup_controller_page_status_on": "バックアップがオンになっています", - "backup_controller_page_storage_format": "使用済み: {}/{}", + "backup_controller_page_storage_format": "使用済み({}) - 全体({})", "backup_controller_page_to_backup": "バックアップされるアルバム", "backup_controller_page_total": "合計", "backup_controller_page_total_sub": "選択されたアルバムの写真と動画の数", @@ -131,8 +135,8 @@ "cache_settings_clear_cache_button": "キャッシュをクリア", "cache_settings_clear_cache_button_title": "キャッシュを削除 (キャッシュが再生成されるまで、アプリのパフォーマンスが著しく低下します)", "cache_settings_duplicated_assets_clear_button": "クリア", - "cache_settings_duplicated_assets_subtitle": "アプリがブラックリストに追加している項目", - "cache_settings_duplicated_assets_title": "{}項目が重複", + "cache_settings_duplicated_assets_subtitle": "サーバーにアップロード済みと認識された写真や動画の数", + "cache_settings_duplicated_assets_title": "{}項目の重複", "cache_settings_image_cache_size": "キャッシュのサイズ ({}枚) ", "cache_settings_statistics_album": "ライブラリのサムネイル", "cache_settings_statistics_assets": "{}枚 ({}枚中)", @@ -150,21 +154,21 @@ "change_password_form_new_password": "新しいパスワード", "change_password_form_password_mismatch": "パスワードが一致しません", "change_password_form_reenter_new_password": "再度パスワードを入力してください", - "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_dialog_msg_confirm": "了解", + "client_cert_enter_password": "パスワードを入力", + "client_cert_import": "インポート", + "client_cert_import_success_msg": "クライアント証明書が導入されました", + "client_cert_invalid_msg": "パスワードが間違っているか証明書が無効です", + "client_cert_remove": "削除", + "client_cert_remove_msg": "クライアント証明書が削除されました", + "client_cert_subtitle": "PKCS12 (.p12 .pfx) フォーマットのみ対応されてます。証明書の導入や削除はログイン前のみ行えます", + "client_cert_title": "SSLクライアント証明書", "common_add_to_album": "アルバムに追加", "common_change_password": "パスワードを変更", "common_create_new_album": "アルバムを作成", "common_server_error": "ネットワーク接続を確認し、サーバーが接続できる状態にあるか確認してください。アプリとサーバーのバージョンが一致しているかも確認してください。", "common_shared": "共有済み", - "contextual_search": "Sunrise on the beach", + "contextual_search": "ビーチと朝日", "control_bottom_app_bar_add_to_album": "アルバムに追加", "control_bottom_app_bar_album_info": "{}枚", "control_bottom_app_bar_album_info_shared": "{}枚 · 共有済", @@ -172,9 +176,9 @@ "control_bottom_app_bar_create_new_album": "アルバムを作成", "control_bottom_app_bar_delete": "削除", "control_bottom_app_bar_delete_from_immich": "Immichから削除", - "control_bottom_app_bar_delete_from_local": "デバイスから削除", - "control_bottom_app_bar_download": "Download", - "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_delete_from_local": "端末上から削除", + "control_bottom_app_bar_download": "ダウンロード", + "control_bottom_app_bar_edit": "編集", "control_bottom_app_bar_edit_location": "位置情報を編集", "control_bottom_app_bar_edit_time": "日時を変更", "control_bottom_app_bar_favorite": "お気に入り", @@ -185,20 +189,22 @@ "control_bottom_app_bar_unarchive": "アーカイブを解除", "control_bottom_app_bar_unfavorite": "お気に入りから外す", "control_bottom_app_bar_upload": "アップロード", + "create_album": "Create album", "create_album_page_untitled": "タイトルなし", + "create_new": "CREATE NEW", "create_shared_album_page_create": "作成", "create_shared_album_page_share": "共有", "create_shared_album_page_share_add_assets": "写真を追加", "create_shared_album_page_share_select_photos": "写真を選択", - "crop": "Crop", + "crop": "クロップ", "curated_location_page_title": "撮影場所", "curated_object_page_title": "被写体", - "daily_title_text_date": "MM月 DD日, EE", - "daily_title_text_date_year": "yyyy年 MM月 DD日, EE", - "date_format": "MM月 DD日, EE • hh時mm分", + "daily_title_text_date": "MM DD, EE", + "daily_title_text_date_year": "yyyy MM DD, EE", + "date_format": "MM DD, EE • hh:mm", "delete_dialog_alert": "サーバーとデバイスの両方から永久的に削除されます!", "delete_dialog_alert_local": "選択された項目はデバイスから削除されますが、Immichには残ります", - "delete_dialog_alert_local_non_backed_up": "Immichにバックアップされていない項目があります。デバイスからも永久に削除されます", + "delete_dialog_alert_local_non_backed_up": "選択された項目のうち、Immichにバックアップされていない物が含まれています。デバイスからも完全に削除されます。", "delete_dialog_alert_remote": "選択された項目はImmichから永久に削除されます", "delete_dialog_cancel": "キャンセル", "delete_dialog_ok": "削除", @@ -210,37 +216,50 @@ "delete_shared_link_dialog_title": "共有リンクを消す", "description_input_hint_text": "説明を追加", "description_input_submit_error": "説明の編集に失敗しました。詳細はログを確認してください。", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "ダウンロードがキャンセルされました", + "download_complete": "ダウンロード完了", + "download_enqueue": "ダウンロード待機中", + "download_error": "ダウンロードエラー", + "download_failed": "ダウンロード失敗", + "download_filename": "ファイル名: {}", + "download_finished": "ダウンロード終了", + "downloading": "ダウンロード中...", + "downloading_media": "ダウンロード中", + "download_notfound": "ダウンロードが見つかりません", + "download_paused": "ダウンロード停止", + "download_started": "ダウンロード開始", + "download_sucess": "ダウンロード成功", + "download_sucess_android": "DCIM/Immichに保存されました", + "download_waiting_to_retry": "リトライ中", "edit_date_time_dialog_date_time": "日付と時間", "edit_date_time_dialog_timezone": "タイムゾーン", - "edit_image_title": "Edit", + "edit_image_title": "編集", "edit_location_dialog_title": "位置情報", - "error_saving_image": "Error: {}", + "error_saving_image": "エラー: {}", "exif_bottom_sheet_description": "説明を追加", "exif_bottom_sheet_details": "詳細", "exif_bottom_sheet_location": "撮影場所", "exif_bottom_sheet_location_add": "位置情報を追加", - "exif_bottom_sheet_people": "ピープル", + "exif_bottom_sheet_people": "人物", "exif_bottom_sheet_person_add_person": "名前を追加", "experimental_settings_new_asset_list_subtitle": "製作途中 (WIP)", "experimental_settings_new_asset_list_title": "試験的なグリッドを有効化", "experimental_settings_subtitle": "試験的機能につき自己責任で!", "experimental_settings_title": "試験的機能", + "favorites": "Favorites", "favorites_page_no_favorites": "お気に入り登録された写真またはビデオがありません", "favorites_page_title": "お気に入り", - "filename_search": "File name or extension", + "filename_search": "ファイル名、又は拡張子", + "filter": "Filter", "haptic_feedback_switch": "ハプティックフィードバック", "haptic_feedback_title": "ハプティックフィードバックを有効にする", - "header_settings_add_header_tip": "Add Header", - "header_settings_field_validator_msg": "Value cannot be empty", - "header_settings_header_name_input": "Header name", - "header_settings_header_value_input": "Header value", - "header_settings_page_title": "Proxy Headers", - "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", - "headers_settings_tile_title": "Custom proxy headers", + "header_settings_add_header_tip": "ヘッダを追加", + "header_settings_field_validator_msg": "ヘッダを空白にはできません", + "header_settings_header_name_input": "ヘッダの名前", + "header_settings_header_value_input": "ヘッダのバリュー", + "header_settings_page_title": "プロキシヘッダ", + "headers_settings_tile_subtitle": "プロキシヘッダを設定する", + "headers_settings_tile_title": "カスタムプロキシヘッダ", "home_page_add_to_album_conflicts": "{album}に{added}枚写真を追加しました。追加済みの{failed}枚はスキップしました。", "home_page_add_to_album_err_local": "まだアップロードされてない項目は、アルバムに登録できません", "home_page_add_to_album_success": "{album}に{added}枚写真を追加しました", @@ -249,19 +268,22 @@ "home_page_archive_err_partner": "パートナーの写真はアーカイブできません。スキップします", "home_page_building_timeline": "タイムライン構築中", "home_page_delete_err_partner": "パートナーの写真は削除できません。スキップします", - "home_page_delete_remote_err_local": "リモート削除の選択にローカルなアイテムが含まれています。スキップします", + "home_page_delete_remote_err_local": "サーバー上のアイテムの削除の選択に端末上のアイテムが含まれているのでスキップします", "home_page_favorite_err_local": "まだアップロードされてない項目はお気に入り登録できません", "home_page_favorite_err_partner": "まだパートナーの写真をお気に入り登録できません。スキップします (アップデートをお待ちください)", "home_page_first_time_notice": "はじめてアプリを使う場合、タイムラインに写真を表示するためにアルバムを選択してください", "home_page_share_err_local": "ローカルのみの項目をリンクで共有はできません。スキップします", "home_page_upload_err_limit": "1回でアップロードできる写真の数は30枚です。スキップします", - "image_saved_successfully": "Image saved", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "画像が保存されました", "image_viewer_page_state_provider_download_error": "ダウンロード失敗", "image_viewer_page_state_provider_download_started": "ダウンロードが始まります", "image_viewer_page_state_provider_download_success": "ダウンロード成功", "image_viewer_page_state_provider_share_error": "共有エラー", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", + "invalid_date": "日付が無効です", + "invalid_date_format": "日付のフォーマットが無効です", + "library": "Library", "library_page_albums": "アルバム", "library_page_archive": "アーカイブ", "library_page_device_albums": "デバイス上のアルバム", @@ -330,26 +352,28 @@ "map_settings_include_show_partners": "パートナーを含める", "map_settings_only_relative_range": "日付", "map_settings_only_show_favorites": "お気に入りのみを表示", - "map_settings_theme_settings": "マップの見た目", + "map_settings_theme_settings": "地図の見た目", "map_zoom_to_see_photos": "写真を見るにはズームアウト", "memories_all_caught_up": "すべて確認済み", "memories_check_back_tomorrow": "明日もう一度確認してください", "memories_start_over": "始める", "memories_swipe_to_close": "上にスワイプして閉じる", - "memories_year_ago": "A year ago", - "memories_years_ago": "{} years ago", - "monthly_title_text_date_format": "yyyy年 MM月", + "memories_year_ago": "一年前", + "memories_years_ago": "{}年前", + "monthly_title_text_date_format": "yyyy MM", "motion_photos_page_title": "モーションフォト", "multiselect_grid_edit_date_time_err_read_only": "読み取り専用の項目の日付を変更できません", "multiselect_grid_edit_gps_err_read_only": "読み取り専用の項目の位置情報を変更できません", + "my_albums": "My albums", "no_assets_to_show": "表示する項目がありません", - "no_name": "No name", + "no_name": "名前がありません", "notification_permission_dialog_cancel": "キャンセル", "notification_permission_dialog_content": "通知を許可するには設定を開いてオンにしてください", "notification_permission_dialog_settings": "設定", "notification_permission_list_tile_content": "通知の許可 をオンにしてください", "notification_permission_list_tile_enable_button": "通知をオンにする", "notification_permission_list_tile_title": "通知の許可", + "on_this_device": "On this device", "partner_list_user_photos": "{user}さんの写真", "partner_list_view_all": "すべて見る", "partner_page_add_partner": "パートナーを追加", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{}は写真へアクセスできなくなります", "partner_page_stop_sharing_title": "写真の共有を無効化しますか?", "partner_page_title": "パートナー", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "戻る", "permission_onboarding_continue_anyway": "無視して続行", "permission_onboarding_get_started": "はじめる", @@ -371,53 +397,56 @@ "permission_onboarding_permission_granted": "写真へのアクセスが許可されました", "permission_onboarding_permission_limited": "写真へのアクセスが制限されています。Immichが写真のバックアップと管理を行うには、システム設定から写真と動画のアクセス権限を変更してください。", "permission_onboarding_request": "Immichは写真へのアクセス許可が必要です", + "places": "Places", "preferences_settings_title": "設定", "profile_drawer_app_logs": "ログ", "profile_drawer_client_out_of_date_major": "アプリが更新されてません。最新のバージョンに更新してください", "profile_drawer_client_out_of_date_minor": "アプリが更新されてません。最新のバージョンに更新してください", - "profile_drawer_client_server_up_to_date": "すべて最新です", - "profile_drawer_documentation": "Immichのドキュメント", + "profile_drawer_client_server_up_to_date": "すべて最新版です", + "profile_drawer_documentation": "Immich公式サイト(英語のみ)", "profile_drawer_github": "GitHub", "profile_drawer_server_out_of_date_major": "サーバーが更新されてません。最新のバージョンに更新してください", "profile_drawer_server_out_of_date_minor": "サーバーが更新されてません。最新のバージョンに更新してください", "profile_drawer_settings": "設定", "profile_drawer_sign_out": "サインアウト", "profile_drawer_trash": "ゴミ箱", + "recently_added": "Recently added", "recently_added_page_title": "最近", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "ギャラリーに保存", "scaffold_body_error_occurred": "エラーが発生しました", + "search_albums": "Search albums", "search_bar_hint": "写真を検索", "search_filter_apply": "フィルターを適用する", - "search_filter_camera": "Camera", + "search_filter_camera": "カメラ", "search_filter_camera_make": "メーカー", "search_filter_camera_model": "モデル", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "カメラの種類を選択", + "search_filter_date": "撮影日", + "search_filter_date_interval": "{start}から{end}まで", + "search_filter_date_title": "撮影期間を選択", "search_filter_display_option_archive": "アーカイブ", "search_filter_display_option_favorite": "お気に入り", "search_filter_display_option_not_in_album": "アルバムにありません", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "表示オプション", + "search_filter_display_options_title": "表示オプション", + "search_filter_location": "場所", "search_filter_location_city": "市町村", "search_filter_location_country": "国", "search_filter_location_state": "都道府県", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", + "search_filter_location_title": "場所を選択", + "search_filter_media_type": "メディアの種類", "search_filter_media_type_all": "すべて", "search_filter_media_type_image": "写真", - "search_filter_media_type_title": "Select media type", + "search_filter_media_type_title": "メディアの種類を選択", "search_filter_media_type_video": "動画", - "search_filter_people": "People", - "search_filter_people_title": "Select people", + "search_filter_people": "人物", + "search_filter_people_title": "人物を選択", "search_page_categories": "カテゴリ", "search_page_favorites": "お気に入り", "search_page_motion_photos": "モーションフォト", "search_page_no_objects": "被写体に関するデータがなし", "search_page_no_places": "場所に関するデータなし", - "search_page_people": "ピープル", + "search_page_people": "人物", "search_page_person_add_name_dialog_cancel": "キャンセル", "search_page_person_add_name_dialog_hint": "名前", "search_page_person_add_name_dialog_save": "保存", @@ -444,11 +473,11 @@ "server_info_box_latest_release": "最新バージョン", "server_info_box_server_url": " サーバーのURL", "server_info_box_server_version": "サーバーのバージョン", - "setting_image_viewer_help": "写真をタップするとサムネイル・中画質(要設定)・オリジナル(要設定)の順に読み込みます", - "setting_image_viewer_original_subtitle": "オリジナルの画像を表示したいときにオンにしてください。(最大画質で表示されるので、モバイルデータとストレージの消費量が増えます)", - "setting_image_viewer_original_title": "オリジナル画像を読み込む", - "setting_image_viewer_preview_subtitle": "中画質の写真をロードしたいときにオンにしてください。直接最大画質の写真を表示したい場合はオフにしてください。(ロード中はサムネイルが代わりに表示されます)", - "setting_image_viewer_preview_title": "プレビュー画像をロードする", + "setting_image_viewer_help": "写真をタップするとサムネイル・中画質・オリジナルの順に読み込みます", + "setting_image_viewer_original_subtitle": "オリジナルの画像を表示したいときにオンにしてください。(最大画質で表示されるので、データと端末のストレージの消費量が増えます)", + "setting_image_viewer_original_title": "オリジナルを読み込む", + "setting_image_viewer_preview_subtitle": "中画質の写真をロードしたいときにオンにしてください。このステップをスキップして直接最大画質の写真を表示したい場合はオフにしてください。(ロード中はサムネイルが代わりに表示されます)", + "setting_image_viewer_preview_title": "プレビューを読み込む", "setting_image_viewer_title": "画像", "setting_languages_apply": "適用する", "setting_languages_title": "言語", @@ -459,14 +488,14 @@ "setting_notifications_notify_never": "行わない", "setting_notifications_notify_seconds": "{}秒後", "setting_notifications_single_progress_subtitle": "アップロード中の写真の詳細", - "setting_notifications_single_progress_title": "実行中のバックアップの詳細を表示", + "setting_notifications_single_progress_title": "バックアップの詳細な進行状況を表示", "setting_notifications_subtitle": "通知設定を変更する", "setting_notifications_title": "通知", "setting_notifications_total_progress_subtitle": "アップロードの進行状況 (完了済み/全体枚数)", - "setting_notifications_total_progress_title": "実行中のバックアップの進行状況を表示", + "setting_notifications_total_progress_title": "全体のバックアップの進行状況を表示", "setting_pages_app_bar_settings": "設定", "settings_require_restart": "Immichを再起動して設定を適用してください", - "setting_video_viewer_looping_subtitle": "有効にするとディテールビューで自動で動画がループします", + "setting_video_viewer_looping_subtitle": "有効にすると詳細表示で動画がループします", "setting_video_viewer_looping_title": "ループ中", "setting_video_viewer_title": "ビデオ", "share_add": "追加", @@ -480,11 +509,11 @@ "shared_album_activity_remove_title": "アクティビティを削除します", "shared_album_activity_setting_subtitle": "他のユーザーの返信を許可する", "shared_album_activity_setting_title": "お気に入りとコメント", - "shared_album_section_people_action_error": "アルバムからの退出に失敗", + "shared_album_section_people_action_error": "退出に失敗", "shared_album_section_people_action_leave": "ユーザーをアルバムから退出", "shared_album_section_people_action_remove_user": "ユーザーをアルバムから退出", - "shared_album_section_people_owner_label": "オーナー", - "shared_album_section_people_title": "ピープル", + "shared_album_section_people_owner_label": "アルバム作成者", + "shared_album_section_people_title": "人物", "share_dialog_preparing": "準備中", "shared_link_app_bar_title": "共有リンク", "shared_link_clipboard_copied_massage": "クリップボードにコピーしました", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "アップロード", "shared_link_manage_links": "共有済みのリンクを管理", "shared_link_public_album": "公開アルバム", + "shared_links": "Shared links", "share_done": "完了", + "shared_with_me": "Shared with me", "share_invite": "アルバムに招待", "sharing_page_album": "共有アルバム", "sharing_page_description": "共有アルバムを作成して同じネットワークにいる人たちに写真を共有", @@ -539,31 +570,32 @@ "sharing_silver_appbar_create_shared_album": "共有アルバムを作成", "sharing_silver_appbar_shared_links": "共有リンク", "sharing_silver_appbar_share_partner": "パートナーと共有", - "sync": "Sync", - "sync_albums": "Sync albums", - "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sync": "同期", + "sync_albums": "アルバムを同期", + "sync_albums_manual_subtitle": "アップロード済みの全ての写真や動画を選択されたバックアップアルバムに同期する", + "sync_upload_album_setting_subtitle": "サーバー上のアルバムの内容を端末上のアルバムと同期します (サーバーにアルバムが無い場合自動で作成されます。また、アップロードされていない写真や動画は同期されません)", "tab_controller_nav_library": "ライブラリ", "tab_controller_nav_photos": "写真", "tab_controller_nav_search": "検索", "tab_controller_nav_sharing": "共有", "theme_setting_asset_list_storage_indicator_title": "ストレージに関する情報を表示", - "theme_setting_asset_list_tiles_per_row_title": "一列ごとの枚数: {}", - "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", - "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_asset_list_tiles_per_row_title": "一列ごとの表示枚数: {}", + "theme_setting_colorful_interface_subtitle": "アクセントカラーを背景にも使用する", + "theme_setting_colorful_interface_title": "カラフルなUI", "theme_setting_dark_mode_switch": "ダークモード", "theme_setting_image_viewer_quality_subtitle": "画像ビューの画質の設定", "theme_setting_image_viewer_quality_title": "画像ビュー", - "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", - "theme_setting_primary_color_title": "Primary color", - "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_primary_color_subtitle": "アクセント用の色を選択", + "theme_setting_primary_color_title": "アクセントカラー", + "theme_setting_system_primary_color_title": "端末で設定されている色を使う", "theme_setting_system_theme_switch": "自動 (デバイスの設定を反映)", "theme_setting_theme_subtitle": "テーマ設定", "theme_setting_theme_title": "テーマ", "theme_setting_three_stage_loading_subtitle": "三段階読み込みを有効にすると、パフォーマンスが改善する可能性がありますが、ネットワーク負荷が著しく増加します。", "theme_setting_three_stage_loading_title": "三段階読み込みをオンにする", "translated_text_options": "オプション", - "trash_emptied": "Emptied trash", + "trash": "Trash", + "trash_emptied": "ゴミ箱を空にしました", "trash_page_delete": "削除", "trash_page_delete_all": "すべて削除", "trash_page_empty_trash_btn": "コミ箱を空にする", @@ -582,10 +614,11 @@ "upload_dialog_title": "アップロード", "version_announcement_overlay_ack": "了解", "version_announcement_overlay_release_notes": "更新情報", - "version_announcement_overlay_text_1": "こんにちは!新しい", + "version_announcement_overlay_text_1": "新しい", "version_announcement_overlay_text_2": "のバージョンが公開中です。", - "version_announcement_overlay_text_3": "を確認してみてください。docker-composeや.envファイルが最新の状態に更新されているか、特にWatchTowerなどのツールを使ってDockerイメージを自動アップデートしてる人は確認してください。", - "version_announcement_overlay_title": "サーバーの新バージョンリリース\uD83C\uDF89", + "version_announcement_overlay_text_3": "を確認してください。docker-composeや.envファイルが最新の状態に更新済みか、特にWatchTowerなどのツールを使ってDockerイメージを自動アップデートされてる方は確認してください。", + "version_announcement_overlay_title": "サーバーの最新版が公開中\uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "スタックから外す", "viewer_stack_use_as_main_asset": "メインの画像として使用する", "viewer_unstack": "スタックを解除" diff --git a/mobile/assets/i18n/ko-KR.json b/mobile/assets/i18n/ko-KR.json index e6da75c2f6f28f..090925e7249490 100644 --- a/mobile/assets/i18n/ko-KR.json +++ b/mobile/assets/i18n/ko-KR.json @@ -6,6 +6,7 @@ "action_common_save": "저장", "action_common_select": "선택", "action_common_update": "업데이트", + "add_a_name": "이름 추가", "add_to_album_bottom_sheet_added": "{album}에 추가되었습니다.", "add_to_album_bottom_sheet_already_exists": "{album}에 이미 존재하는 항목입니다.", "advanced_settings_log_level_title": "로그 레벨: {}", @@ -21,8 +22,9 @@ "advanced_settings_troubleshooting_title": "문제 해결", "album_info_card_backup_album_excluded": "제외됨", "album_info_card_backup_album_included": "선택됨", - "album_thumbnail_card_item": "1개 항목", - "album_thumbnail_card_items": "{}개 항목", + "albums": "앨범", + "album_thumbnail_card_item": "항목 1개", + "album_thumbnail_card_items": "항목 {}개", "album_thumbnail_card_shared": " · 공유됨", "album_thumbnail_owned": "소유함", "album_thumbnail_shared_by": "{}님이 공유함", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "앨범에서 제거", "album_viewer_appbar_share_to": "공유 대상", "album_viewer_page_share_add_users": "사용자 추가", + "all": "모두", "all_people_page_title": "인물", "all_videos_page_title": "동영상", "app_bar_signout_dialog_content": "정말 로그아웃하시겠습니까?", "app_bar_signout_dialog_ok": "네", "app_bar_signout_dialog_title": "로그아웃", + "archived": "아카이브", "archive_page_no_archived_assets": "보관된 항목 없음", "archive_page_title": "보관함 ({})", "asset_action_delete_err_read_only": "읽기 전용 항목은 삭제할 수 없습니다. 건너뜁니다.", @@ -56,11 +60,11 @@ "asset_list_settings_title": "사진 배열", "asset_restored_successfully": "항목이 성공적으로 복원되었습니다.", "assets_deleted_permanently": "{}개 항목이 영구적으로 삭제됨", - "assets_deleted_permanently_from_server": "{}개 항목이 Immich 서버에서 영구적으로 삭제됨", - "assets_removed_permanently_from_device": "{}개 항목이 기기에서 영구적으로 삭제됨", + "assets_deleted_permanently_from_server": "Immich에서 항목 {}개가 영구적으로 삭제됨", + "assets_removed_permanently_from_device": "기기에서 항목 {}개가 영구적으로 삭제됨", "assets_restored_successfully": "항목 {}개를 복원했습니다.", - "assets_trashed": "휴지통으로 {}개 항목이 이동되었습니다.", - "assets_trashed_from_server": "휴지통으로 Immich 서버의 {}개 항목이 이동되었습니다.", + "assets_trashed": "휴지통으로 항목 {}개가 이동되었습니다.", + "assets_trashed_from_server": "휴지통으로 Immich 항목 {}개가 이동되었습니다.", "asset_viewer_settings_title": "보기 옵션", "backup_album_selection_page_albums_device": "기기의 앨범 ({})", "backup_album_selection_page_albums_tap": "한 번 눌러 선택, 두 번 눌러 제외하세요.", @@ -166,8 +170,8 @@ "common_shared": "공유됨", "contextual_search": "동해안에서 맞이하는 새해 일출", "control_bottom_app_bar_add_to_album": "앨범에 추가", - "control_bottom_app_bar_album_info": "{}개 항목", - "control_bottom_app_bar_album_info_shared": "{}개 항목 · 공유됨", + "control_bottom_app_bar_album_info": "항목 {}개", + "control_bottom_app_bar_album_info_shared": "항목 {}개 · 공유됨", "control_bottom_app_bar_archive": "보관", "control_bottom_app_bar_create_new_album": "앨범 생성", "control_bottom_app_bar_delete": "삭제", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "보관 해제", "control_bottom_app_bar_unfavorite": "즐겨찾기 해제", "control_bottom_app_bar_upload": "업로드", + "create_album": "앨범 생성", "create_album_page_untitled": "제목 없음", + "create_new": "새로 만들기", "create_shared_album_page_create": "생성", "create_shared_album_page_share": "공유", "create_shared_album_page_share_add_assets": "항목 추가", @@ -196,10 +202,10 @@ "daily_title_text_date": "M월 d일 EEEE", "daily_title_text_date_year": "yyyy년 M월 d일 EEEE", "date_format": "yyyy년 M월 d일 EEEE • a h:mm", - "delete_dialog_alert": "선택한 항목이 Immich 및 기기에서 영구적으로 삭제됩니다.", - "delete_dialog_alert_local": "선택한 항목이 이 기기에서 영구적으로 삭제됩니다. Immich 서버에서는 계속 사용할 수 있습니다.", - "delete_dialog_alert_local_non_backed_up": "일부 항목은 Immich에 백업되지 않으며 기기에서 영구적으로 삭제됩니다.", - "delete_dialog_alert_remote": "선택한 항목이 Immich 서버에서 영구적으로 삭제됩니다.", + "delete_dialog_alert": "이 항목이 Immich 및 기기에서 영구적으로 삭제됩니다.", + "delete_dialog_alert_local": "이 항목이 기기에서 영구적으로 삭제됩니다. Immich에서는 삭제되지 않습니다.", + "delete_dialog_alert_local_non_backed_up": "일부 항목이 백업되지 않았습니다. 백업되지 않은 항목이 기기에서 영구적으로 삭제됩니다.", + "delete_dialog_alert_remote": "이 항목이 Immich에서 영구적으로 삭제됩니다.", "delete_dialog_cancel": "취소", "delete_dialog_ok": "삭제", "delete_dialog_ok_force": "무시하고 삭제", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "공유 링크 삭제", "description_input_hint_text": "설명 추가...", "description_input_submit_error": "설명을 변경하는 중 문제가 발생했습니다. 자세한 내용은 로그를 참조하세요.", - "download_error": "다운로드 중 문제가 발생했습니다.", - "download_started": "다운로드가 시작되었습니다.", - "download_sucess": "다운로드가 완료되었습니다.", - "download_sucess_android": "미디어가 DCIM/Immich에 저장되었습니다.", + "download_canceled": "다운로드가 취소되었습니다", + "download_complete": "다은로드가 완료되었습니다", + "download_enqueue": "대기열에 다운로드", + "download_error": "다운로드 중 문제가 발생했습니다", + "download_failed": "다운로드에 실패하였습니다", + "download_filename": "파일: {}", + "download_finished": "다운로드가 완료되었습니다", + "downloading": "다운로드 중...", + "downloading_media": "미디어 다운로드 중", + "download_notfound": "다운로드할 수 없음", + "download_paused": "다운로드 일시 중지됨", + "download_started": "다운로드가 시작되었습니다", + "download_sucess": "다운로드가 완료되었습니다", + "download_sucess_android": "미디어가 DCIM/Immich에 저장되었습니다", + "download_waiting_to_retry": "재시도 대기 중", "edit_date_time_dialog_date_time": "날짜 및 시간", "edit_date_time_dialog_timezone": "시간대", "edit_image_title": "편집", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "새 사진 배열 사용 (실험적)", "experimental_settings_subtitle": "본인 책임 하에 사용하세요!", "experimental_settings_title": "실험적", + "favorites": "즐겨찾기", "favorites_page_no_favorites": "즐겨찾기된 항목 없음", "favorites_page_title": "즐겨찾기", "filename_search": "파일 이름 또는 확장자", + "filter": "필터", "haptic_feedback_switch": "햅틱 피드백 활성화", "haptic_feedback_title": "햅틱 피드백", "header_settings_add_header_tip": "헤더 추가", @@ -255,13 +274,16 @@ "home_page_first_time_notice": "앱을 처음 사용하는 경우 타임라인에 앨범의 사진과 동영상을 채울 수 있도록 백업할 앨범을 선택하세요.", "home_page_share_err_local": "기기의 항목은 링크로 공유할 수 없습니다. 건너뜁니다.", "home_page_upload_err_limit": "한 번에 최대 30개의 항목만 업로드할 수 있습니다.", - "image_saved_successfully": "이미지가 저장되었습니다.", + "ignore_icloud_photos": "iCloud 사진 제외", + "ignore_icloud_photos_description": "iCloud에 저장된 사진은 Immich 서버에 업로드되지 않습니다.", + "image_saved_successfully": "이미지가 저장되었습니다", "image_viewer_page_state_provider_download_error": "다운로드 오류", "image_viewer_page_state_provider_download_started": "다운로드가 시작되었습니다.", "image_viewer_page_state_provider_download_success": "다운로드 완료", "image_viewer_page_state_provider_share_error": "공유 오류", "invalid_date": "잘못된 날짜입니다.", "invalid_date_format": "잘못된 날짜 형식입니다.", + "library": "라이브러리", "library_page_albums": "앨범", "library_page_archive": "보관함", "library_page_device_albums": "기기의 앨범", @@ -342,6 +364,7 @@ "motion_photos_page_title": "모션 포토", "multiselect_grid_edit_date_time_err_read_only": "읽기 전용 항목의 날짜는 변경할 수 없습니다. 건너뜁니다.", "multiselect_grid_edit_gps_err_read_only": "읽기 전용 항목의 위치는 변경할 수 없습니다. 건너뜁니다.", + "my_albums": "내 앨범", "no_assets_to_show": "표시할 항목 없음", "no_name": "이름 없음", "notification_permission_dialog_cancel": "취소", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "알림을 활성화하려면 권한을 부여하세요.", "notification_permission_list_tile_enable_button": "알림 활성화", "notification_permission_list_tile_title": "알림 권한", + "on_this_device": "이 장치에서", "partner_list_user_photos": "{user}님의 사진", "partner_list_view_all": "모두 보기", "partner_page_add_partner": "파트너 추가", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "더 이상 {}님이 사진에 접근할 수 없습니다.", "partner_page_stop_sharing_title": "공유를 중단하시겠습니까?", "partner_page_title": "파트너", + "partners": "파트너", + "people": "인물", "permission_onboarding_back": "뒤로", "permission_onboarding_continue_anyway": "무시하고 진행", "permission_onboarding_get_started": "시작하기", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "권한이 부여되었습니다! 준비가 완료되었습니다.", "permission_onboarding_permission_limited": "권한이 없습니다. Immich가 전체 갤러리 컬렉션을 백업하고 관리할 수 있도록 하려면 설정에서 사진 및 동영상 권한을 부여하세요.", "permission_onboarding_request": "사진 및 동영상 권한이 필요합니다.", + "places": "장소", "preferences_settings_title": "설정", "profile_drawer_app_logs": "로그", "profile_drawer_client_out_of_date_major": "모바일 앱이 최신 버전이 아닙니다. 최신 버전으로 업데이트하세요.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "설정", "profile_drawer_sign_out": "로그아웃", "profile_drawer_trash": "휴지통", + "recently_added": "최근 추가", "recently_added_page_title": "최근 추가", "save_to_gallery": "갤러리에 저장", "scaffold_body_error_occurred": "문제가 발생했습니다.", + "search_albums": "앨범 검색", "search_bar_hint": "사진 검색", "search_filter_apply": "필터 적용", "search_filter_camera": "카메라", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "업로드", "shared_link_manage_links": "공유 링크 관리", "shared_link_public_album": "공개 앨범", + "shared_links": "공유 링크", "share_done": "완료", + "shared_with_me": "나와 공유됨", "share_invite": "앨범으로 초대", "sharing_page_album": "공유 앨범", "sharing_page_description": "공유 앨범을 만들어 주변 사람들과 사진 및 동영상을 공유하세요.", @@ -542,7 +573,7 @@ "sync": "동기화", "sync_albums": "앨범 동기화", "sync_albums_manual_subtitle": "업로드한 모든 동영상과 사진을 선택한 백업 앨범에 동기화", - "sync_upload_album_setting_subtitle": "선택한 앨범을 Immich에 생성하고 사진 및 동영상을 업로드하세요.", + "sync_upload_album_setting_subtitle": "선택한 앨범을 Immich에 생성하고 사진 및 동영상 업로드", "tab_controller_nav_library": "라이브러리", "tab_controller_nav_photos": "사진", "tab_controller_nav_search": "검색", @@ -563,11 +594,12 @@ "theme_setting_three_stage_loading_subtitle": "이 기능은 앱의 로드 성능을 향상시킬 수 있지만 더 많은 데이터를 사용합니다.", "theme_setting_three_stage_loading_title": "3단계 로드 활성화", "translated_text_options": "옵션", - "trash_emptied": "휴지통을 비움", + "trash": "쓰레기통", + "trash_emptied": "휴지통을 비웠습니다.", "trash_page_delete": "삭제", "trash_page_delete_all": "모두 삭제", "trash_page_empty_trash_btn": "휴지통 비우기", - "trash_page_empty_trash_dialog_content": "휴지통을 비우시겠습니까? 휴지통에 있는 항목이 Immich에서 영구적으로 제거됩니다.", + "trash_page_empty_trash_dialog_content": "휴지통을 비우시겠습니까? 휴지통에 있는 모든 항목이 Immich에서 영구적으로 제거됩니다.", "trash_page_empty_trash_dialog_ok": "확인", "trash_page_info": "휴지통으로 이동된 항목은 {}일 후 영구적으로 삭제됩니다.", "trash_page_no_assets": "휴지통이 비어 있음", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "새 버전의 Immich를 사용할 수 있습니다.", "version_announcement_overlay_text_3": "WatchTower 등의 자동 업데이트 기능을 사용하는 경우 의도하지 않은 동작을 방지하기 위해 docker-compose.yml 및 .env 구성이 최신인지 확인하세요.", "version_announcement_overlay_title": "새 서버 버전 사용 가능 \uD83C\uDF89", + "videos": "동영상", "viewer_remove_from_stack": "스택에서 제거", "viewer_stack_use_as_main_asset": "대표 사진으로 설정", "viewer_unstack": "스택 해제" diff --git a/mobile/assets/i18n/lt-LT.json b/mobile/assets/i18n/lt-LT.json index 324c9069fdf460..0075f65de0557f 100644 --- a/mobile/assets/i18n/lt-LT.json +++ b/mobile/assets/i18n/lt-LT.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" diff --git a/mobile/assets/i18n/lv-LV.json b/mobile/assets/i18n/lv-LV.json index c9f86535fc5a39..b49e2f5af75c30 100644 --- a/mobile/assets/i18n/lv-LV.json +++ b/mobile/assets/i18n/lv-LV.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Atjaunināt", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Pievienots {album}", "add_to_album_bottom_sheet_already_exists": "Jau pievienots {album}", "advanced_settings_log_level_title": "Žurnalēšanas līmenis: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Problēmas novēršana", "album_info_card_backup_album_excluded": "NEIEKĻAUTS", "album_info_card_backup_album_included": "IEKĻAUTS", + "albums": "Albums", "album_thumbnail_card_item": "1 vienums", "album_thumbnail_card_items": "{} vienumi", "album_thumbnail_card_shared": "· Koplietots", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Noņemt no albuma", "album_viewer_appbar_share_to": "Kopīgot Uz", "album_viewer_page_share_add_users": "Pievienot lietotājus", + "all": "All", "all_people_page_title": "Cilvēki", "all_videos_page_title": "Videoklipi", "app_bar_signout_dialog_content": "Vai tiešām vēlaties izrakstīties?", "app_bar_signout_dialog_ok": "Jā", "app_bar_signout_dialog_title": "Izrakstīties", + "archived": "Archived", "archive_page_no_archived_assets": "Nav atrasts neviens arhivēts aktīvs", "archive_page_title": "Arhīvs ({})", "asset_action_delete_err_read_only": "Nevar dzēst read only aktīvu(-s), notiek izlaišana", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Atarhivēt", "control_bottom_app_bar_unfavorite": "Noņemt no Izlases", "control_bottom_app_bar_upload": "Augšupielādēt", + "create_album": "Create album", "create_album_page_untitled": "Bez nosaukuma", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Izveidot", "create_shared_album_page_share": "Kopīgot", "create_shared_album_page_share_add_assets": "PIEVIENOT AKTĪVUS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Dzēst Kopīgošanas saiti", "description_input_hint_text": "Pievienot aprakstu...", "description_input_submit_error": "Atjauninot aprakstu, radās kļūda; papildinformāciju skatiet žurnālā", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Datums un Laiks", "edit_date_time_dialog_timezone": "Laika zona", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Iespējot eksperimentālo fotorežģi", "experimental_settings_subtitle": "Izmanto uzņemoties risku!", "experimental_settings_title": "Eksperimentāls", + "favorites": "Favorites", "favorites_page_no_favorites": "Nav atrasti iecienītākie aktīvi", "favorites_page_title": "Izlase", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Iestatīt haptisku reakciju", "haptic_feedback_title": "Haptiska Reakcija", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Ja šī ir pirmā reize, kad izmantojat aplikāciju, lūdzu, izvēlieties dublējuma albumu(s), lai laika skala varētu aizpildīt fotoattēlus un videoklipus albumā(os).", "home_page_share_err_local": "Caur saiti nevarēja kopīgot lokālos aktīvus, notiek izlaišana", "home_page_upload_err_limit": "Vienlaikus var augšupielādēt ne vairāk kā 30 aktīvus, notiek izlaišana", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Lejupielādes Kļūda", "image_viewer_page_state_provider_download_started": "Lejupielāde Uzsākta", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Kopīgošanas Kļūda", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Arhīvs", "library_page_device_albums": "Albumi ierīcē", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Kustību Fotoattēli", "multiselect_grid_edit_date_time_err_read_only": "Nevar rediģēt read only aktīva(-u) datumu, notiek izlaišana", "multiselect_grid_edit_gps_err_read_only": "Nevar rediģēt atrašanās vietu read only aktīva(-u) datumu, notiek izlaišana", + "my_albums": "My albums", "no_assets_to_show": "Nav uzrādāmo aktīvu", "no_name": "No name", "notification_permission_dialog_cancel": "Atcelt", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Piešķirt atļauju, lai iespējotu paziņojumus.", "notification_permission_list_tile_enable_button": "Iespējot Paziņojumus", "notification_permission_list_tile_title": "Paziņojumu Atļaujas", + "on_this_device": "On this device", "partner_list_user_photos": "{user} fotoattēli", "partner_list_view_all": "Apskatīt visu", "partner_page_add_partner": "Pievienot partneri", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} vairs nevarēs piekļūt jūsu fotoattēliem.", "partner_page_stop_sharing_title": "Beigt kopīgot jūsu fotogrāfijas?", "partner_page_title": "Partneris", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Atpakaļ", "permission_onboarding_continue_anyway": "Tomēr turpināt", "permission_onboarding_get_started": "Darba sākšana", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Atļauja piešķirta! Jūs esat gatavi darbam.", "permission_onboarding_permission_limited": "Atļauja ierobežota. Lai atļautu Immich dublēšanu un varētu pārvaldīt visu galeriju kolekciju, sadaļā Iestatījumi piešķiriet fotoattēlu un video atļaujas.", "permission_onboarding_request": "Immich nepieciešama atļauja skatīt jūsu fotoattēlus un videoklipus.", + "places": "Places", "preferences_settings_title": "Iestatījumi", "profile_drawer_app_logs": "Žurnāli", "profile_drawer_client_out_of_date_major": "Mobilā Aplikācija ir novecojusi. Lūdzu atjaunojiet to uz jaunāko lielo versiju", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Iestatījumi", "profile_drawer_sign_out": "Izrakstīties", "profile_drawer_trash": "Atkritne", + "recently_added": "Recently added", "recently_added_page_title": "Nesen Pievienotais", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Radās kļūda", + "search_albums": "Search albums", "search_bar_hint": "Meklēt Jūsu fotoattēlus", "search_filter_apply": "Lietot filtru", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Augšupielādēt", "shared_link_manage_links": "Pārvaldīt Kopīgotās saites", "shared_link_public_album": "Publisks albums", + "shared_links": "Shared links", "share_done": "Gatavs", + "shared_with_me": "Shared with me", "share_invite": "Uzaicināt albumā", "sharing_page_album": "Kopīgotie albumi", "sharing_page_description": "Izveidojiet koplietojamus albumus, lai kopīgotu fotoattēlus un videoklipus ar Jūsu tīkla lietotājiem.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Trīspakāpju ielāde var palielināt ielādēšanas veiktspēju, bet izraisa ievērojami lielāku tīkla noslodzi", "theme_setting_three_stage_loading_title": "Iespējot trīspakāpju ielādi", "translated_text_options": "Iestatījumi", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Dzēst", "trash_page_delete_all": "Dzēst Visu", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "lūdzu, veltiet laiku, lai apmeklētu", "version_announcement_overlay_text_3": " un pārliecinieties, vai docker-compose un .env iestatījumi ir atjaunināti, lai novērstu jebkādas nepareizas konfigurācijas, īpaši, ja izmantojat WatchTower vai mehānismu, kas automātiski veic servera lietojumprogrammas atjaunināšanu.", "version_announcement_overlay_title": "Pieejama jauna servera versija \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Noņemt no Steka", "viewer_stack_use_as_main_asset": "Izmantot kā Galveno Aktīvu", "viewer_unstack": "At-Stekot" diff --git a/mobile/assets/i18n/mn-MN.json b/mobile/assets/i18n/mn-MN.json index 54697af5da3240..66392ed47a60d4 100644 --- a/mobile/assets/i18n/mn-MN.json +++ b/mobile/assets/i18n/mn-MN.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Цуцлах", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Мэдэгдэл нээх эрх өгнө үү.\n", "notification_permission_list_tile_enable_button": "Мэдэгдэл нээх", "notification_permission_list_tile_title": "Мэдэгдлийн эрх", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,7 +618,8 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "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/assets/i18n/nb-NO.json b/mobile/assets/i18n/nb-NO.json index 7141faef726870..8bc8402fd3f318 100644 --- a/mobile/assets/i18n/nb-NO.json +++ b/mobile/assets/i18n/nb-NO.json @@ -6,6 +6,7 @@ "action_common_save": "Lagre", "action_common_select": "Velg", "action_common_update": "Oppdater", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Lagt til i {album}", "add_to_album_bottom_sheet_already_exists": "Allerede i {album}", "advanced_settings_log_level_title": "Loggnivå: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Feilsøking", "album_info_card_backup_album_excluded": "EKSKLUDERT", "album_info_card_backup_album_included": "INKLUDERT", + "albums": "Albums", "album_thumbnail_card_item": "1 objekt", "album_thumbnail_card_items": "{} objekter", "album_thumbnail_card_shared": " · Delt", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Fjern fra album", "album_viewer_appbar_share_to": "Del til", "album_viewer_page_share_add_users": "Legg til brukere", + "all": "All", "all_people_page_title": "Folk", "all_videos_page_title": "Videoer", "app_bar_signout_dialog_content": "Er du sikker på at du vil logge ut?", "app_bar_signout_dialog_ok": "Ja", "app_bar_signout_dialog_title": "Logg ut", + "archived": "Archived", "archive_page_no_archived_assets": "Ingen arkiverte objekter funnet", "archive_page_title": "Arkiv ({})", "asset_action_delete_err_read_only": "Kan ikke slette objekt(er) med kun lese-rettighet, hopper over", @@ -54,13 +58,13 @@ "asset_list_layout_sub_title": "Fordeling", "asset_list_settings_subtitle": "Innstillinger for layout av fotorutenett", "asset_list_settings_title": "Fotorutenett", - "asset_restored_successfully": "Asset restored successfully", - "assets_deleted_permanently": "{} asset(s) deleted permanently", - "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", - "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", - "assets_restored_successfully": "{} asset(s) restored successfully", - "assets_trashed": "{} asset(s) trashed", - "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_restored_successfully": "{} objekt(er) Gjenopprettet", + "assets_deleted_permanently": "{} objekt(er) Slettet permanent", + "assets_deleted_permanently_from_server": "{} objekt(er) slettet permanent fra Immich serveren", + "assets_removed_permanently_from_device": "{} objekt(er) slettet permanent fra enheten din", + "assets_restored_successfully": "{} objekt(er) gjenopprettet", + "assets_trashed": "{} objekt(er) slettet", + "assets_trashed_from_server": "{} objekt(er) slettet fra Immich serveren", "asset_viewer_settings_title": "Objektviser", "backup_album_selection_page_albums_device": "Album på enhet ({})", "backup_album_selection_page_albums_tap": "Trykk for å inkludere, dobbelttrykk for å ekskludere", @@ -173,7 +177,7 @@ "control_bottom_app_bar_delete": "Slett", "control_bottom_app_bar_delete_from_immich": "Slett fra Immich", "control_bottom_app_bar_delete_from_local": "Slett fra enhet", - "control_bottom_app_bar_download": "Download", + "control_bottom_app_bar_download": "Last ned", "control_bottom_app_bar_edit": "Endre", "control_bottom_app_bar_edit_location": "Endre lokasjon", "control_bottom_app_bar_edit_time": "Endre Dato og tid", @@ -185,12 +189,14 @@ "control_bottom_app_bar_unarchive": "Fjern fra arkiv", "control_bottom_app_bar_unfavorite": "Fjern favoritt", "control_bottom_app_bar_upload": "Last opp", + "create_album": "Create album", "create_album_page_untitled": "Uten navn", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Opprett", "create_shared_album_page_share": "Del", "create_shared_album_page_share_add_assets": "LEGG TIL OBJEKTER", "create_shared_album_page_share_select_photos": "Velg bilder", - "crop": "Crop", + "crop": "Beskjær", "curated_location_page_title": "Plasseringer", "curated_object_page_title": "Ting", "daily_title_text_date": "E, MMM dd", @@ -210,15 +216,26 @@ "delete_shared_link_dialog_title": "Slett delt link", "description_input_hint_text": "Legg til beskrivelse ...", "description_input_submit_error": "Feil ved oppdatering av beskrivelse, sjekk loggen for flere detaljer", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Nedlasting avbrutt", + "download_complete": "Nedlasting fullført", + "download_enqueue": "Nedlasting satt i kø", + "download_error": "Nedlasting feilet", + "download_failed": "Nedlasting feilet", + "download_filename": "fil: {}", + "download_finished": "Nedlasting fullført", + "downloading": "Laster ned...", + "downloading_media": "Laster ned media", + "download_notfound": "Nedlasting ikke funnet", + "download_paused": "Nedlasting pauset", + "download_started": "Nedlasting startet", + "download_sucess": "Nedlasting vellykket", + "download_sucess_android": "Objektet har blitt lastet ned til DCIM/Immich", + "download_waiting_to_retry": "Venter på nytt forsøk", "edit_date_time_dialog_date_time": "Dato og tid", "edit_date_time_dialog_timezone": "Tidssone", - "edit_image_title": "Edit", + "edit_image_title": "Endre", "edit_location_dialog_title": "Lokasjon", - "error_saving_image": "Error: {}", + "error_saving_image": "Feil: {}", "exif_bottom_sheet_description": "Legg til beskrivelse ...", "exif_bottom_sheet_details": "DETALJER", "exif_bottom_sheet_location": "PLASSERING", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Aktiver eksperimentell rutenettsvisning", "experimental_settings_subtitle": "Bruk på egen risiko!", "experimental_settings_title": "Eksperimentelt", + "favorites": "Favorites", "favorites_page_no_favorites": "Ingen favorittobjekter funnet", "favorites_page_title": "Favoritter", "filename_search": "Filnavn eller filtype", + "filter": "Filter", "haptic_feedback_switch": "Aktivert haptisk tilbakemelding", "haptic_feedback_title": "Haptisk tilbakemelding", "header_settings_add_header_tip": "Legg til header", @@ -255,13 +274,16 @@ "home_page_first_time_notice": "Hvis dette er første gangen du benytter appen, velg et album (eller flere) for sikkerhetskopiering, slik at tidslinjen kan fylles med dine bilder og videoer.", "home_page_share_err_local": "Kan ikke dele lokale objekter via link, hopper over", "home_page_upload_err_limit": "Maksimalt 30 objekter kan lastes opp om gangen, hopper over", - "image_saved_successfully": "Image saved", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Bilde lagret", "image_viewer_page_state_provider_download_error": "Nedlasting feilet", "image_viewer_page_state_provider_download_started": "Nedlasting startet", "image_viewer_page_state_provider_download_success": "Nedlasting vellykket", "image_viewer_page_state_provider_share_error": "Delingsfeil", "invalid_date": "Ugyldig dato", "invalid_date_format": "Ugyldig datoformat", + "library": "Library", "library_page_albums": "Albumer", "library_page_archive": "Arkiv", "library_page_device_albums": "Albumer på enheten", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Bevegelige bilder", "multiselect_grid_edit_date_time_err_read_only": "Kan ikke endre dato på objekt(er) med kun lese-rettigheter, hopper over", "multiselect_grid_edit_gps_err_read_only": "Kan ikke endre lokasjon på objekt(er) med kun lese-rettigheter, hopper over", + "my_albums": "My albums", "no_assets_to_show": "Ingen objekter å vise", "no_name": "Ingen navn", "notification_permission_dialog_cancel": "Avbryt", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Gi tilgang for å aktivere notifikasjoner", "notification_permission_list_tile_enable_button": "Aktiver notifikasjoner", "notification_permission_list_tile_title": "Notifikasjonstilgang", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s bilder", "partner_list_view_all": "Vis alle", "partner_page_add_partner": "Legg til partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} vil ikke lenger ha tilgang til dine bilder.", "partner_page_stop_sharing_title": "Stopp deling av bildene dine?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Tilbake", "permission_onboarding_continue_anyway": "Fortsett uansett", "permission_onboarding_get_started": "Kom i gang", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Tilgang gitt! Du er i gang.", "permission_onboarding_permission_limited": "Begrenset tilgang. For å la Immich sikkerhetskopiere og håndtere galleriet, tillatt bilde- og video-tilgang i Innstillinger.", "permission_onboarding_request": "Immich trenger tilgang til å se dine bilder og videoer", + "places": "Places", "preferences_settings_title": "Innstillinger", "profile_drawer_app_logs": "Logg", "profile_drawer_client_out_of_date_major": "Mobilapp er utdatert. Vennligst oppdater til nyeste versjon.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Innstillinger", "profile_drawer_sign_out": "Logg ut", "profile_drawer_trash": "Søppelbøtte", + "recently_added": "Recently added", "recently_added_page_title": "Nylig lagt til", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "Lagre til galleriet", "scaffold_body_error_occurred": "Feil oppstått", + "search_albums": "Search albums", "search_bar_hint": "Søk i dine bilder", "search_filter_apply": "Aktiver filter", "search_filter_camera": "Kamera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Last opp", "shared_link_manage_links": "Håndter delte linker", "shared_link_public_album": "Offentlig album", + "shared_links": "Shared links", "share_done": "Ferdig", + "shared_with_me": "Shared with me", "share_invite": "Inviter til album", "sharing_page_album": "Delte album", "sharing_page_description": "Lag delte albumer for å dele bilder og videoer med folk i nettverket ditt.", @@ -539,10 +570,10 @@ "sharing_silver_appbar_create_shared_album": "Lag delt album", "sharing_silver_appbar_shared_links": "Delte linker", "sharing_silver_appbar_share_partner": "Del med partner", - "sync": "Sync", - "sync_albums": "Sync albums", - "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sync": "Synkroniser", + "sync_albums": "Synkroniser albumer", + "sync_albums_manual_subtitle": "Synkroniser alle opplastede videoer og bilder til det valgte backupalbumet", + "sync_upload_album_setting_subtitle": "Opprett og last opp dine bilder og videoer til det valgte albumet på Immich", "tab_controller_nav_library": "Bibliotek", "tab_controller_nav_photos": "Bilder", "tab_controller_nav_search": "Søk", @@ -563,7 +594,8 @@ "theme_setting_three_stage_loading_subtitle": "Tre-trinns innlasting kan øke lasteytelsen, men forårsaker betydelig høyere nettverksbelastning", "theme_setting_three_stage_loading_title": "Aktiver tre-trinns innlasting", "translated_text_options": "Valg", - "trash_emptied": "Emptied trash", + "trash": "Trash", + "trash_emptied": "Søppelbøtte tømt", "trash_page_delete": "Slett", "trash_page_delete_all": "Slett alt", "trash_page_empty_trash_btn": "Tøm søppelbøtte", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "vennligst ta deg tid til å besøke ", "version_announcement_overlay_text_3": " og verifiser at docker-compose og .env-oppsettet ditt er oppdatert for å forhindre en eventuell feilkonfigurasjon, spesielt hvis du benytter WatchTower eller en annen tjeneste som håndterer oppdatering av server-applikasjonen automatisk.", "version_announcement_overlay_title": "Ny serverversjon tilgjengelig", + "videos": "Videos", "viewer_remove_from_stack": "Fjern fra stabling", "viewer_stack_use_as_main_asset": "Bruk som hovedobjekt", "viewer_unstack": "avstable" diff --git a/mobile/assets/i18n/nl-NL.json b/mobile/assets/i18n/nl-NL.json index a6a151d5069f07..2bf277da1294bc 100644 --- a/mobile/assets/i18n/nl-NL.json +++ b/mobile/assets/i18n/nl-NL.json @@ -6,6 +6,7 @@ "action_common_save": "Opslaan", "action_common_select": "Selecteren", "action_common_update": "Bijwerken", + "add_a_name": "Naam toevoegen", "add_to_album_bottom_sheet_added": "Toegevoegd aan {album}", "add_to_album_bottom_sheet_already_exists": "Staat al in {album}", "advanced_settings_log_level_title": "Log niveau: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Probleemoplossing", "album_info_card_backup_album_excluded": "UITGESLOTEN", "album_info_card_backup_album_included": "INBEGREPEN", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Gedeeld", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Verwijder uit album", "album_viewer_appbar_share_to": "Delen via", "album_viewer_page_share_add_users": "Gebruikers toevoegen", + "all": "Alle", "all_people_page_title": "Mensen", "all_videos_page_title": "Video's", "app_bar_signout_dialog_content": "Weet je zeker dat je wilt uitloggen?", "app_bar_signout_dialog_ok": "Ja", "app_bar_signout_dialog_title": "Log uit", + "archived": "Gearchiveerd", "archive_page_no_archived_assets": "Geen gearchiveerde assets gevonden", "archive_page_title": "Archief ({})", "asset_action_delete_err_read_only": "Kan alleen-lezen asset(s) niet verwijderen, overslaan", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Herstellen", "control_bottom_app_bar_unfavorite": "Onfavoriet", "control_bottom_app_bar_upload": "Uploaden", + "create_album": "Album aanmaken", "create_album_page_untitled": "Naamloos", + "create_new": "MAAK NIEUW", "create_shared_album_page_create": "Aanmaken", "create_shared_album_page_share": "Delen", "create_shared_album_page_share_add_assets": "ASSETS TOEVOEGEN", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Verwijder gedeelde link", "description_input_hint_text": "Beschrijving toevoegen...", "description_input_submit_error": "Beschrijving bijwerken mislukt, controleer het logboek voor meer details", + "download_canceled": "Download geannuleerd", + "download_complete": "Download voltooid", + "download_enqueue": "Download in wachtrij", "download_error": "Fout bij downloaden", + "download_failed": "Download mislukt", + "download_filename": "bestand: {}", + "download_finished": "Download voltooid", + "downloading": "Downloaden...", + "downloading_media": "Media aan het downloaden", + "download_notfound": "Download niet gevonden", + "download_paused": "Download gepauseerd", "download_started": "Download gestart", "download_sucess": "Succesvol gedownload", "download_sucess_android": "Het bestand is gedownload naar DCIM/Immich", + "download_waiting_to_retry": "Wachten om opnieuw te proberen", "edit_date_time_dialog_date_time": "Datum en tijd", "edit_date_time_dialog_timezone": "Tijdzone", "edit_image_title": "Bewerken", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Experimenteel fotoraster inschakelen", "experimental_settings_subtitle": "Gebruik op eigen risico!", "experimental_settings_title": "Experimenteel", + "favorites": "Favorieten", "favorites_page_no_favorites": "Geen favoriete assets gevonden", "favorites_page_title": "Favorieten", "filename_search": "Bestandsnaam of extensie", + "filter": "Filter", "haptic_feedback_switch": "Aanraaktrillingen inschakelen", "haptic_feedback_title": "Aanraaktrillingen", "header_settings_add_header_tip": "Header toevoegen", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Als dit de eerste keer is dat je de app gebruikt, zorg er dan voor dat je een back-up album kiest, zodat de tijdlijn gevuld kan worden met foto's en video's uit het album.", "home_page_share_err_local": "Lokale assets kunnen niet via een link gedeeld worden, overslaan", "home_page_upload_err_limit": "Kan maximaal 30 assets tegelijk uploaden, overslaan", + "ignore_icloud_photos": "Negeer iCloud foto's", + "ignore_icloud_photos_description": "Foto's die op iCloud zijn opgeslagen, worden niet geüpload naar de Immich server", "image_saved_successfully": "Afbeelding opgeslagen", "image_viewer_page_state_provider_download_error": "Download mislukt", "image_viewer_page_state_provider_download_started": "Download gestart", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Deel Error", "invalid_date": "Ongeldige datum", "invalid_date_format": "Ongeldig datumformaat", + "library": "Bibliotheek", "library_page_albums": "Albums", "library_page_archive": "Archief", "library_page_device_albums": "Albums op apparaat", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Bewegende foto's", "multiselect_grid_edit_date_time_err_read_only": "Kan datum van alleen-lezen asset(s) niet wijzigen, overslaan", "multiselect_grid_edit_gps_err_read_only": "Kan locatie van alleen-lezen asset(s) niet wijzigen, overslaan", + "my_albums": "Mijn albums", "no_assets_to_show": "Geen foto's om te laten zien", "no_name": "Geen naam", "notification_permission_dialog_cancel": "Annuleren", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Geef toestemming om meldingen te versturen.", "notification_permission_list_tile_enable_button": "Meldingen inschakelen", "notification_permission_list_tile_title": "Meldingen toestaan", + "on_this_device": "Op dit apparaat", "partner_list_user_photos": "Foto's van {user}", "partner_list_view_all": "Bekijk alle", "partner_page_add_partner": "Partner toevoegen", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} zal geen toegang meer hebben tot je fotos's.", "partner_page_stop_sharing_title": "Stoppen met het delen van je foto's?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "Mensen", "permission_onboarding_back": "Terug", "permission_onboarding_continue_anyway": "Toch doorgaan", "permission_onboarding_get_started": "Aan de slag", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Toestemming verleend. Je bent helemaal klaar.", "permission_onboarding_permission_limited": "Beperkte toestemming. Geef toestemming tot foto's en video's in Instellingen om Immich een back-up te laten maken van je galerij en deze te beheren.", "permission_onboarding_request": "Immich heeft toestemming nodig om je foto's en video's te bekijken.", + "places": "Plaatsen", "preferences_settings_title": "Voorkeuren", "profile_drawer_app_logs": "Logboek", "profile_drawer_client_out_of_date_major": "Mobiele app is verouderd. Werk bij naar de nieuwste hoofdversie.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Instellingen", "profile_drawer_sign_out": "Uitloggen", "profile_drawer_trash": "Prullenbak", + "recently_added": "Onlangs toegevoegd", "recently_added_page_title": "Recent toegevoegd", "save_to_gallery": "Opslaan in galerij", "scaffold_body_error_occurred": "Fout opgetreden", + "search_albums": "Albums zoeken", "search_bar_hint": "Foto's doorzoeken", "search_filter_apply": "Filter toepassen", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Uploaden", "shared_link_manage_links": "Beheer gedeelde links", "shared_link_public_album": "Publiek album", + "shared_links": "Gedeelde links", "share_done": "Klaar", + "shared_with_me": "Gedeeld met mij", "share_invite": "Uitnodigen voor album", "sharing_page_album": "Gedeelde albums", "sharing_page_description": "Maak gedeelde albums om foto's en video's te delen met mensen in je netwerk.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Laden in drie fasen kan de laadprestaties verbeteren, maar veroorzaakt een aanzienlijk hogere netwerkbelasting", "theme_setting_three_stage_loading_title": "Laden in drie fasen inschakelen", "translated_text_options": "Opties", + "trash": "Prullenbak", "trash_emptied": "Prullenbak geleegd", "trash_page_delete": "Verwijderen", "trash_page_delete_all": "Verwijder alle", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "neem je tijd en bezoek de ", "version_announcement_overlay_text_3": " en controleer of je docker-compose en .env up-to-date zijn, om misconfiguraties te voorkomen, in het bijzonder als je gebruik maakt van WatchTower of een ander mechanisme dat je serverapplicatie automatisch bijwerkt.", "version_announcement_overlay_title": "Nieuwe serverversie beschikbaar \uD83C\uDF89", + "videos": "Video's", "viewer_remove_from_stack": "Verwijder van Stapel", "viewer_stack_use_as_main_asset": "Gebruik als Hoofd Asset", "viewer_unstack": "Ontstapel" diff --git a/mobile/assets/i18n/pl-PL.json b/mobile/assets/i18n/pl-PL.json index ec9009e28ff650..3a6ba9f3b45f09 100644 --- a/mobile/assets/i18n/pl-PL.json +++ b/mobile/assets/i18n/pl-PL.json @@ -6,12 +6,13 @@ "action_common_save": "Zapisz", "action_common_select": "Wybierz", "action_common_update": "Aktualizuj", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Dodano do {album}", "add_to_album_bottom_sheet_already_exists": "Już w {album}", "advanced_settings_log_level_title": "Poziom dziennika: {}", "advanced_settings_prefer_remote_subtitle": "Niektóre urządzenia bardzo wolno ładują miniatury z zasobów na urządzeniu. Aktywuj to ustawienie, aby ładować zdalne obrazy.", "advanced_settings_prefer_remote_title": "Preferuj obrazy zdalne", - "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", + "advanced_settings_proxy_headers_subtitle": "Zdefiniuj nagłówki proxy, które Immich powinien wysyłać z każdym żądaniem sieciowym", "advanced_settings_proxy_headers_title": "Nagłówki proxy", "advanced_settings_self_signed_ssl_subtitle": "Pomija weryfikację certyfikatu SSL dla punktu końcowego serwera. Wymagane w przypadku certyfikatów z podpisem własnym.", "advanced_settings_self_signed_ssl_title": "Zezwalaj na certyfikaty SSL z podpisem własnym", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Rozwiązywanie problemów", "album_info_card_backup_album_excluded": "WYKLUCZONE", "album_info_card_backup_album_included": "WŁĄCZONE", + "albums": "Albums", "album_thumbnail_card_item": "1 pozycja", "album_thumbnail_card_items": "{} pozycje", "album_thumbnail_card_shared": "Udostępniony", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Usuń z albumu", "album_viewer_appbar_share_to": "Udostępnij", "album_viewer_page_share_add_users": "Dodaj użytkowników", + "all": "All", "all_people_page_title": "Ludzie", "all_videos_page_title": "Filmy", "app_bar_signout_dialog_content": "Czy na pewno chcesz się wylogować?", "app_bar_signout_dialog_ok": "Tak", "app_bar_signout_dialog_title": "Wyloguj się", + "archived": "Archived", "archive_page_no_archived_assets": "Nie znaleziono zarchiwizowanych zasobów", "archive_page_title": "Archiwum ({})", "asset_action_delete_err_read_only": "Nie można usunąć zasobów tylko do odczytu, pomijam", @@ -152,13 +156,13 @@ "change_password_form_reenter_new_password": "Wprowadź ponownie Nowe Hasło", "client_cert_dialog_msg_confirm": "OK", "client_cert_enter_password": "Wprowadź hasło", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", + "client_cert_import": "Importuj", + "client_cert_import_success_msg": "Certyfikat klienta został zaimportowany", + "client_cert_invalid_msg": "Nieprawidłowy plik certyfikatu lub nieprawidłowe hasło", + "client_cert_remove": "Usuń", + "client_cert_remove_msg": "Certyfikat klienta został usunięty", + "client_cert_subtitle": "Obsługuje tylko format PKCS12 (.p12, .pfx). Import/Usunięcie certyfikatu jest dostępne tylko przed zalogowaniem", + "client_cert_title": "Certyfikat klienta SSL", "common_add_to_album": "Dodaj do albumu", "common_change_password": "Zmień Hasło", "common_create_new_album": "Utwórz nowy album", @@ -185,12 +189,14 @@ "control_bottom_app_bar_unarchive": "Cofnij archiwizację", "control_bottom_app_bar_unfavorite": "Nieulubione", "control_bottom_app_bar_upload": "Prześlij", + "create_album": "Create album", "create_album_page_untitled": "Bez tytułu", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Utwórz album", "create_shared_album_page_share": "Udostępnij", "create_shared_album_page_share_add_assets": "DODAJ ZASOBY", "create_shared_album_page_share_select_photos": "Zaznacz Zdjęcia", - "crop": "Crop", + "crop": "Przytnij", "curated_location_page_title": "Miejsca", "curated_object_page_title": "Rzeczy", "daily_title_text_date": "E, MMM dd", @@ -210,15 +216,26 @@ "delete_shared_link_dialog_title": "Usuń udostępniony link", "description_input_hint_text": "Dodaj opis...", "description_input_submit_error": "Błąd aktualizacji opisu, sprawdź dziennik, aby uzyskać więcej szczegółów", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", + "download_error": "Błąd pobierania", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", + "download_started": "Pobieranie rozpoczęte", + "download_sucess": "Udane pobieranie", + "download_sucess_android": "Media zostały pobrane do DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Data i godzina", "edit_date_time_dialog_timezone": "Strefa czasowa", - "edit_image_title": "Edit", + "edit_image_title": "Edytuj", "edit_location_dialog_title": "Lokalizacja", - "error_saving_image": "Error: {}", + "error_saving_image": "Błąd: {}", "exif_bottom_sheet_description": "Dodaj Opis...", "exif_bottom_sheet_details": "SZCZEGÓŁY", "exif_bottom_sheet_location": "LOKALIZACJA", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Włącz eksperymentalną układ zdjęć", "experimental_settings_subtitle": "Używaj na własne ryzyko!", "experimental_settings_title": "Eksperymentalny", + "favorites": "Favorites", "favorites_page_no_favorites": "Nie znaleziono ulubionych zasobów", "favorites_page_title": "Ulubione", "filename_search": "Nazwa pliku lub rozszerzenie", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Dodaj nagłówek", @@ -255,13 +274,16 @@ "home_page_first_time_notice": "Jeśli korzystasz z aplikacji po raz pierwszy, pamiętaj o wybraniu albumów zapasowych, aby oś czasu mogła zapełnić zdjęcia i filmy w albumach.", "home_page_share_err_local": "Nie można udostępniać zasobów lokalnych za pośrednictwem linku, pomijajam", "home_page_upload_err_limit": "Można przesłać maksymalnie 30 zasobów jednocześnie, pomijanie", - "image_saved_successfully": "Image saved", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Obraz zapisany", "image_viewer_page_state_provider_download_error": "Błąd pobierania", "image_viewer_page_state_provider_download_started": "Pobieranie rozpoczęte", "image_viewer_page_state_provider_download_success": "Pobieranie zakończone", "image_viewer_page_state_provider_share_error": "Udostępnij błąd", "invalid_date": "Nieprawidłowa data", "invalid_date_format": "Nieprawidłowy format daty", + "library": "Library", "library_page_albums": "Albumy", "library_page_archive": "Archiwum", "library_page_device_albums": "Albumy na Urządzeniu", @@ -342,14 +364,16 @@ "motion_photos_page_title": "Zdjęcia ruchome", "multiselect_grid_edit_date_time_err_read_only": "Nie można edytować daty zasobów tylko do odczytu, pomijanie", "multiselect_grid_edit_gps_err_read_only": "Nie można edytować lokalizacji zasobów tylko do odczytu, pomijanie", + "my_albums": "My albums", "no_assets_to_show": "Brak zasobów do pokazania", - "no_name": "No name", + "no_name": "Bez nazwy", "notification_permission_dialog_cancel": "Anuluj", "notification_permission_dialog_content": "Aby włączyć powiadomienia, przejdź do Ustawień i wybierz opcję Zezwalaj.", "notification_permission_dialog_settings": "Ustawienia", "notification_permission_list_tile_content": "Przyznaj uprawnienia, aby włączyć powiadomienia.", "notification_permission_list_tile_enable_button": "Włącz Powiadomienia", "notification_permission_list_tile_title": "Pozwolenie na powiadomienia", + "on_this_device": "On this device", "partner_list_user_photos": "{user} zdjęcia", "partner_list_view_all": "Pokaż wszystkie", "partner_page_add_partner": "Dodaj partnera", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} nie będziesz już mieć dostępu do swoich zdjęć.", "partner_page_stop_sharing_title": "Przestać udostępniać swoje zdjęcia?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Cofnij", "permission_onboarding_continue_anyway": "Kontynuuj mimo to", "permission_onboarding_get_started": "Rozpocznij", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Pozwolenie udzielone! Wszystko gotowe.", "permission_onboarding_permission_limited": "Pozwolenie ograniczone. Aby umożliwić Immichowi tworzenie kopii zapasowych całej kolekcji galerii i zarządzanie nią, przyznaj uprawnienia do zdjęć i filmów w Ustawieniach.", "permission_onboarding_request": "Immich potrzebuje pozwolenia na przeglądanie Twoich zdjęć i filmów.", + "places": "Places", "preferences_settings_title": "Ustawienia", "profile_drawer_app_logs": "Logi", "profile_drawer_client_out_of_date_major": "Aplikacja mobilna jest nieaktualna. Zaktualizuj do najnowszej wersji głównej.", @@ -383,24 +410,26 @@ "profile_drawer_settings": "Ustawienia", "profile_drawer_sign_out": "Wyloguj się", "profile_drawer_trash": "Kosz", + "recently_added": "Recently added", "recently_added_page_title": "Ostatnio Dodane", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "Zapisz w galerii", "scaffold_body_error_occurred": "Wystąpił błąd", + "search_albums": "Search albums", "search_bar_hint": "Szukaj swoich zdjęć", "search_filter_apply": "Zastosuj filtr", - "search_filter_camera": "Camera", + "search_filter_camera": "Kamera", "search_filter_camera_make": "Make", "search_filter_camera_model": "Model", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", + "search_filter_camera_title": "Wybierz typ kamery", + "search_filter_date": "Data", + "search_filter_date_interval": "{start} do {end}", + "search_filter_date_title": "Wybierz zakres dat", "search_filter_display_option_archive": "Archiwum", "search_filter_display_option_favorite": "Ulubiony", "search_filter_display_option_not_in_album": "Nie w albumie", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", + "search_filter_display_options": "Opcje wyświetlania", + "search_filter_display_options_title": "Opcje wyświetlania", + "search_filter_location": "Lokalizacja", "search_filter_location_city": "Miasto", "search_filter_location_country": "Kraj", "search_filter_location_state": "State", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Wgraj", "shared_link_manage_links": "Zarządzaj udostępnionymi linkami", "shared_link_public_album": "Album publiczny", + "shared_links": "Shared links", "share_done": "Zrobione", + "shared_with_me": "Shared with me", "share_invite": "Zaproś do albumu", "sharing_page_album": "Udostępnione albumy", "sharing_page_description": "Twórz wspóldzielone albumy, aby udostępniać zdjęcia i filmy osobom w sieci.", @@ -539,10 +570,10 @@ "sharing_silver_appbar_create_shared_album": "Utwórz współdzielony album", "sharing_silver_appbar_shared_links": "Udostępnione linki", "sharing_silver_appbar_share_partner": "Udostępnij partnerce/partnerowi", - "sync": "Sync", - "sync_albums": "Sync albums", - "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sync": "Synchronizuj", + "sync_albums": "Synchronizuj albumy", + "sync_albums_manual_subtitle": "Zsynchronizuj wszystkie przesłane filmy i zdjęcia z wybranymi albumami kopii zapasowych", + "sync_upload_album_setting_subtitle": "Twórz i przesyłaj swoje zdjęcia i filmy do wybranych albumów w Immich", "tab_controller_nav_library": "Biblioteka", "tab_controller_nav_photos": "Zdjęcia", "tab_controller_nav_search": "Szukaj", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Trójstopniowe ładowanie może zwiększyć wydajność ładowania, ale powoduje znacznie większe obciążenie sieci", "theme_setting_three_stage_loading_title": "Włączenie trójstopniowego ładowania", "translated_text_options": "Opcje", + "trash": "Trash", "trash_emptied": "Opróżnione śmieci", "trash_page_delete": "Usuń", "trash_page_delete_all": "Usuń wszystko", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "prosimy o poświęcenie czasu na odwiedzenie ", "version_announcement_overlay_text_3": " i upewnij się, że twoja konfiguracja docker-compose i .env jest aktualna, aby zapobiec błędnym konfiguracjom, zwłaszcza jeśli używasz WatchTower lub dowolnego mechanizmu, który obsługuje automatyczną aktualizację aplikacji serwera.", "version_announcement_overlay_title": "Nowa wersja serwera dostępna \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Usuń ze stosu", "viewer_stack_use_as_main_asset": "Użyj jako głównego zasobu", "viewer_unstack": "Usuń stos" diff --git a/mobile/assets/i18n/pt-PT.json b/mobile/assets/i18n/pt-PT.json index 991fdfaf361f4f..1adae1b1ec536c 100644 --- a/mobile/assets/i18n/pt-PT.json +++ b/mobile/assets/i18n/pt-PT.json @@ -6,6 +6,7 @@ "action_common_save": "Salvar", "action_common_select": "Selecionar", "action_common_update": "Atualizar", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Adicionado a {album}", "add_to_album_bottom_sheet_already_exists": "Já existe em {album}", "advanced_settings_log_level_title": "Nível de log: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Resolução de problemas", "album_info_card_backup_album_excluded": "EXCLUÍDO", "album_info_card_backup_album_included": "INCLUÍDO", + "albums": "Albums", "album_thumbnail_card_item": "1 arquivo", "album_thumbnail_card_items": "{} arquivos", "album_thumbnail_card_shared": " · Compartilhado", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remover do álbum", "album_viewer_appbar_share_to": "Compartilhar com", "album_viewer_page_share_add_users": "Adicionar usuários", + "all": "All", "all_people_page_title": "Pessoas", "all_videos_page_title": "Vídeos", "app_bar_signout_dialog_content": "Tem certeza que deseja sair?", "app_bar_signout_dialog_ok": "Sim", "app_bar_signout_dialog_title": "Sair", + "archived": "Archived", "archive_page_no_archived_assets": "Nenhum arquivo encontrado", "archive_page_title": "Arquivado ({})", "asset_action_delete_err_read_only": "Não é possível excluir arquivo só leitura, ignorando", @@ -185,12 +189,14 @@ "control_bottom_app_bar_unarchive": "Desarquivar", "control_bottom_app_bar_unfavorite": "Remover favorito", "control_bottom_app_bar_upload": "Enviar", + "create_album": "Create album", "create_album_page_untitled": "Sem título", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Criar", "create_shared_album_page_share": "Compartilhar", "create_shared_album_page_share_add_assets": "ADICIONAR ARQUIVOS", "create_shared_album_page_share_select_photos": "Selecionar Fotos", - "crop": "Crop", + "crop": "Cortar", "curated_location_page_title": "Locais", "curated_object_page_title": "Objetos", "daily_title_text_date": "E, MMM dd", @@ -210,15 +216,26 @@ "delete_shared_link_dialog_title": "Excluir link compartilhado", "description_input_hint_text": "Adicionar descrição...", "description_input_submit_error": "Erro ao atualizar a descrição, verifique o registo para obter mais detalhes", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Cancelado", + "download_complete": "Sucesso", + "download_enqueue": "Na fila", + "download_error": "Erro ao baixar", + "download_failed": "Falha", + "download_filename": "arquivo: {}", + "download_finished": "Concluído", + "downloading": "Baixando...", + "downloading_media": "Baixando mídia", + "download_notfound": "Não encontrado", + "download_paused": "Pausado", + "download_started": "Iniciando", + "download_sucess": "Baixado com sucesso", + "download_sucess_android": "O arquivo foi baixado na pasta DCIM/Immich", + "download_waiting_to_retry": "Tentando novamente", "edit_date_time_dialog_date_time": "Data e Hora", "edit_date_time_dialog_timezone": "Fuso horário", - "edit_image_title": "Edit", + "edit_image_title": "Editar", "edit_location_dialog_title": "Localização", - "error_saving_image": "Error: {}", + "error_saving_image": "Erro: {}", "exif_bottom_sheet_description": "Adicionar Descrição...", "exif_bottom_sheet_details": "DETALHES", "exif_bottom_sheet_location": "LOCALIZAÇÃO", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Ativar visualização de grade experimental", "experimental_settings_subtitle": "Use por sua conta e risco!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "Nenhum favorito encontrado", "favorites_page_title": "Favoritos", "filename_search": "Nome do arquivo ou extensão", + "filter": "Filter", "haptic_feedback_switch": "Habilitar vibração", "haptic_feedback_title": "Vibração", "header_settings_add_header_tip": "Adicionar cabeçalho", @@ -255,13 +274,16 @@ "home_page_first_time_notice": "Se é a primeira vez que utiliza o aplicativo, certifique-se de marcar um ou mais álbuns do dispositivo para backup, assim a linha do tempo será preenchida com as fotos e vídeos.", "home_page_share_err_local": "Não é possível compartilhar arquivos locais com um link, ignorando", "home_page_upload_err_limit": "Só é possível enviar 30 arquivos por vez, ignorando", - "image_saved_successfully": "Image saved", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Imagem salva", "image_viewer_page_state_provider_download_error": "Erro ao baixar", "image_viewer_page_state_provider_download_started": "Baixando arquivo", "image_viewer_page_state_provider_download_success": "Baixado com sucesso", "image_viewer_page_state_provider_share_error": "Erro ao compartilhar", "invalid_date": "Data inválida", "invalid_date_format": "Formato de data inválido", + "library": "Library", "library_page_albums": "Álbuns", "library_page_archive": "Arquivado", "library_page_device_albums": "Álbuns no dispositivo", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Fotos com movimento", "multiselect_grid_edit_date_time_err_read_only": "Não é possível editar a data de arquivo só leitura, ignorando", "multiselect_grid_edit_gps_err_read_only": "Não é possível editar a localização de arquivo só leitura, ignorando", + "my_albums": "My albums", "no_assets_to_show": "Não há arquivos para exibir", "no_name": "Sem nome", "notification_permission_dialog_cancel": "Cancelar", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Dar permissões para ativar notificações", "notification_permission_list_tile_enable_button": "Ativar notificações", "notification_permission_list_tile_title": "Permissão de notificações", + "on_this_device": "On this device", "partner_list_user_photos": "Fotos de {user}", "partner_list_view_all": "Ver tudo", "partner_page_add_partner": "Adicionar parceiro", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} não poderá mais acessar as suas fotos.", "partner_page_stop_sharing_title": "Parar de compartilhar as suas fotos?", "partner_page_title": "Parceiro", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Voltar", "permission_onboarding_continue_anyway": "Continuar mesmo assim", "permission_onboarding_get_started": "Começar", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permissão concedida! Está tudo pronto.", "permission_onboarding_permission_limited": "Permissão limitada. Para permitir que o Immich faça backups e gerencie sua galeria, conceda permissões para fotos e vídeos nas configurações.", "permission_onboarding_request": "O Immich requer autorização para ver as suas fotos e vídeos.", + "places": "Places", "preferences_settings_title": "Preferências", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "O aplicativo está desatualizado. Por favor, atualize para a versão mais recente.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Configurações", "profile_drawer_sign_out": "Sair", "profile_drawer_trash": "Lixeira", + "recently_added": "Recently added", "recently_added_page_title": "Adicionado recentemente", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "Salvar na galeria", "scaffold_body_error_occurred": "Ocorreu um erro", + "search_albums": "Search albums", "search_bar_hint": "Pesquisar em suas fotos", "search_filter_apply": "Aplicar filtro", "search_filter_camera": "Câmera", @@ -531,18 +560,20 @@ "shared_link_info_chip_upload": "Enviar", "shared_link_manage_links": "Gerenciar links compartilhados", "shared_link_public_album": "Álbum público", + "shared_links": "Shared links", "share_done": "Feito", + "shared_with_me": "Shared with me", "share_invite": "Convidar para o álbum", "sharing_page_album": "Álbuns compartilhados", "sharing_page_description": "Crie álbuns compartilhados para compartilhar fotos e vídeos com pessoas da sua rede.", "sharing_page_empty_list": "LISTA VAZIA", "sharing_silver_appbar_create_shared_album": "Criar álbum partilhado", "sharing_silver_appbar_shared_links": "Links compartilhados", - "sharing_silver_appbar_share_partner": "Partilhar com parceiro", - "sync": "Sync", - "sync_albums": "Sync albums", - "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "sharing_silver_appbar_share_partner": "Compartilhar com parceiro", + "sync": "Sincronizar", + "sync_albums": "Sincronizar álbuns", + "sync_albums_manual_subtitle": "Sincronizar todas as fotos e vídeos enviados para o álbum de backup selecionado", + "sync_upload_album_setting_subtitle": "Crie e envie suas fotos e vídeos para o álbum selecionado no Immich", "tab_controller_nav_library": "Biblioteca", "tab_controller_nav_photos": "Fotos", "tab_controller_nav_search": "Pesquisar", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "O carregamento em três estágios pode aumentar o desempenho do carregamento, mas causa uma carga de rede significativamente maior", "theme_setting_three_stage_loading_title": "Habilitar carregamento em três estágios", "translated_text_options": "Opções", + "trash": "Trash", "trash_emptied": "Lixeira esvaziada", "trash_page_delete": "Excluir", "trash_page_delete_all": "Excluir tudo", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "por favor, Verifique com calma as ", "version_announcement_overlay_text_3": "e certifique-se de que a configuração do docker-compose e do arquivo .env estejam atualizadas para evitar configurações incorretas, especialmente se utiliza o WatchTower ou qualquer outro mecanismo que faça atualização automática do servidor.", "version_announcement_overlay_title": "Nova versão do servidor disponível \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remover da pilha", "viewer_stack_use_as_main_asset": "Usar como foto principal", "viewer_unstack": "Desempilhar" diff --git a/mobile/assets/i18n/ro-RO.json b/mobile/assets/i18n/ro-RO.json index 4cb043d1962d2a..255940263320e2 100644 --- a/mobile/assets/i18n/ro-RO.json +++ b/mobile/assets/i18n/ro-RO.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Actualizează", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Adăugat în {album}", "add_to_album_bottom_sheet_already_exists": "Deja în {album}", "advanced_settings_log_level_title": "Nivel log: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Depanare", "album_info_card_backup_album_excluded": "EXCLUSE", "album_info_card_backup_album_included": "INCLUSE", + "albums": "Albums", "album_thumbnail_card_item": "1 element", "album_thumbnail_card_items": "{} elemente", "album_thumbnail_card_shared": "Distribuit", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Șterge din album", "album_viewer_appbar_share_to": "Distribuire către", "album_viewer_page_share_add_users": "Adaugă utilizatori", + "all": "All", "all_people_page_title": "Persoane", "all_videos_page_title": "Videoclipuri", "app_bar_signout_dialog_content": "Ești sigur că vrei să te deconectezi?", "app_bar_signout_dialog_ok": "Da", "app_bar_signout_dialog_title": "Deconectare", + "archived": "Archived", "archive_page_no_archived_assets": "Nu au fost găsite resurse favorite", "archive_page_title": "Arhivă ({})", "asset_action_delete_err_read_only": "Fișierele cu permisiuni doar de citire nu au putut fi șterse, omitere", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Șterge din arhivă", "control_bottom_app_bar_unfavorite": "Șterge din favorite", "control_bottom_app_bar_upload": "Încarcă", + "create_album": "Create album", "create_album_page_untitled": "Fără nume", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Creează", "create_shared_album_page_share": "Distribuie", "create_shared_album_page_share_add_assets": "ADAUGĂ RESURSE", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Șterge link distribuire", "description_input_hint_text": "Adaugă descriere...", "description_input_submit_error": "Eroare actualizare descriere, verifică log-urile pentru mai multe detalii", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Dată și Oră", "edit_date_time_dialog_timezone": "Fus orar", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Activează grila experimentală de fotografii.", "experimental_settings_subtitle": "Folosește pe propria răspundere!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "Nu au fost găsite resurse favorite", "favorites_page_title": "Favorite", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Dacă este prima dată când utilizezi aplicația, te rugăm să te asiguri că alegi unul sau mai multe albume de backup, astfel încât cronologia să poată fi populată cu fotografiile și videoclipurile din aceste albume.", "home_page_share_err_local": "Nu se pot distribui fișiere locale prin link, omitere", "home_page_upload_err_limit": "Se pot încărca maxim 30 de resurse odată, omitere", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Eroare descărcare", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Eroare distribuire", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albume", "library_page_archive": "Arhivă", "library_page_device_albums": "Albume în dispozitiv", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Fotografii în mișcare", "multiselect_grid_edit_date_time_err_read_only": "Nu se poate edita data fișierului(lor) cu permisiuni doar pentru citire, omitere", "multiselect_grid_edit_gps_err_read_only": "Nu se poate edita locația fișierului(lor) cu permisiuni doar pentru citire, omitere", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Anulează", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Acordă permisiunea pentru a activa notificările.", "notification_permission_list_tile_enable_button": "Activează notificările", "notification_permission_list_tile_title": "Permisiuni de notificare", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Adaugă partener", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} nu va mai putea accesa fotografiile tale.", "partner_page_stop_sharing_title": "Încetezi distribuirea fotografiilor?", "partner_page_title": "Partener", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Înapoi", "permission_onboarding_continue_anyway": "Continuă oricum", "permission_onboarding_get_started": "Începe", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permisiune acordată!", "permission_onboarding_permission_limited": "Permisiune limitată. Pentru a permite Immich să facă copii de siguranță și să gestioneze întreaga colecție de galerii, acordă permisiuni pentru fotografii și videoclipuri în Setări.", "permission_onboarding_request": "Immich necesită permisiunea de a vizualiza fotografiile și videoclipurile tale.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Log-uri", "profile_drawer_client_out_of_date_major": "Aplicația nu folosește ultima versiune. Te rugăm să actulizezi la ultima versiune majoră.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Setări", "profile_drawer_sign_out": "Deconectare", "profile_drawer_trash": "Coș", + "recently_added": "Recently added", "recently_added_page_title": "Adăugate recent", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "A apărut o eroare", + "search_albums": "Search albums", "search_bar_hint": "Căutare fotografii", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Încarcă", "shared_link_manage_links": "Administrează link-urile distribuite", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Gata", + "shared_with_me": "Shared with me", "share_invite": "Invită în album", "sharing_page_album": "Albume distribuite", "sharing_page_description": "Creeză albume de distribuire pentru a distribui fotografii și videoclipuri cu persoanele din rețeaua ta.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Încărcarea în trei etape are putea crește performanța încărcării dar generează un volum semnificativ mai mare de trafic pe rețea", "theme_setting_three_stage_loading_title": "Pornește încărcarea în 3 etape", "translated_text_options": "Opțiuni", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Șterge", "trash_page_delete_all": "Șterge tot", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "te rugăm verifică", "version_announcement_overlay_text_3": "și asigură-te că fișierul .env și configurația ta docker-compose sunt actualizate pentru a preveni orice erori de configurație, în special dacă folosești WatchTower sau orice mecanism care gestionează actualizarea automată a aplicației server-ului tău.", "version_announcement_overlay_title": "O nouă versiune pentru server este disponibilă \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Șterge din grup", "viewer_stack_use_as_main_asset": "Folosește ca resursă principală", "viewer_unstack": "Anulează grup" diff --git a/mobile/assets/i18n/ru-RU.json b/mobile/assets/i18n/ru-RU.json index 1c5741a963ef27..80e0611d3f7e04 100644 --- a/mobile/assets/i18n/ru-RU.json +++ b/mobile/assets/i18n/ru-RU.json @@ -6,12 +6,13 @@ "action_common_save": "Сохранить", "action_common_select": "Выбрать", "action_common_update": "Обновить", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Добавлено в {album}", "add_to_album_bottom_sheet_already_exists": "Уже в {album}", "advanced_settings_log_level_title": "Log level: {}", "advanced_settings_prefer_remote_subtitle": "Некоторые устройства очень медленно загружают предпросмотр объектов, находящихся на устройстве. Активируйте эту настройку, чтобы вместо них загружались изображения с сервера.", "advanced_settings_prefer_remote_title": "Предпочитать фото на сервере", - "advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.", + "advanced_settings_proxy_headers_subtitle": "Определите заголовки прокси-сервера, которые Immich должен отправлять с каждым сетевым запросом.", "advanced_settings_proxy_headers_title": "Прокси-заголовки", "advanced_settings_self_signed_ssl_subtitle": "Пропускает проверку SSL-сертификата сервера. Требуется для самоподписанных сертификатов.", "advanced_settings_self_signed_ssl_title": "Разрешить самоподписанные SSL-сертификаты", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Решение проблем", "album_info_card_backup_album_excluded": "ИСКЛЮЧЕН", "album_info_card_backup_album_included": "ВКЛЮЧЕН", + "albums": "Albums", "album_thumbnail_card_item": "1 объект", "album_thumbnail_card_items": "{} объектов", "album_thumbnail_card_shared": "· Общий", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Удалить из альбома", "album_viewer_appbar_share_to": "Поделиться", "album_viewer_page_share_add_users": "Добавить пользователей", + "all": "All", "all_people_page_title": "Люди", "all_videos_page_title": "Видео", "app_bar_signout_dialog_content": "Вы уверены, что хотите выйти из системы?", "app_bar_signout_dialog_ok": "Да", "app_bar_signout_dialog_title": "Выйти из системы", + "archived": "Archived", "archive_page_no_archived_assets": "В архиве сейчас пусто", "archive_page_title": "Архив ({})", "asset_action_delete_err_read_only": "Невозможно удалить объект(ы) только для чтения, пропуск...", @@ -185,12 +189,14 @@ "control_bottom_app_bar_unarchive": "Восстановить", "control_bottom_app_bar_unfavorite": "Удалить из избранного", "control_bottom_app_bar_upload": "Загрузить", + "create_album": "Create album", "create_album_page_untitled": "Без названия", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Создать", "create_shared_album_page_share": "Поделиться", "create_shared_album_page_share_add_assets": "ДОБАВИТЬ ОБЪЕКТЫ", "create_shared_album_page_share_select_photos": "Выберите фотографии", - "crop": "Crop", + "crop": "Кадрировать", "curated_location_page_title": "Места", "curated_object_page_title": "Предметы", "daily_title_text_date": "E, MMM dd", @@ -210,15 +216,26 @@ "delete_shared_link_dialog_title": "Удалить общую ссылку", "description_input_hint_text": "Добавить описание...", "description_input_submit_error": "Не удалось обновить описание, проверьте логи, чтобы узнать причину", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Загрузка отменена", + "download_complete": "Загрузка окончена", + "download_enqueue": "Загрузка в очереди", + "download_error": "Ошибка загрузки", + "download_failed": "Загрузка не удалась", + "download_filename": "файл: {}", + "download_finished": "Загрузка окончена", + "downloading": "Загрузка...", + "downloading_media": "Загрузка медиа", + "download_notfound": "Загрузка не обнаружена", + "download_paused": "Загрузка приостановлена", + "download_started": "Загрузка началась", + "download_sucess": "Успешная загрузка", + "download_sucess_android": "Медиафайлы загружены в DCIM/Immich", + "download_waiting_to_retry": "Ожидание повторной попытки", "edit_date_time_dialog_date_time": "Дата и время", "edit_date_time_dialog_timezone": "Часовой пояс", - "edit_image_title": "Edit", + "edit_image_title": "Редактировать", "edit_location_dialog_title": "Местоположение", - "error_saving_image": "Error: {}", + "error_saving_image": "Ошибка: {}", "exif_bottom_sheet_description": "Добавить описание...", "exif_bottom_sheet_details": "ПОДРОБНОСТИ", "exif_bottom_sheet_location": "Местоположение", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Включить экспериментальную сетку фотографий", "experimental_settings_subtitle": "Используйте на свой страх и риск!", "experimental_settings_title": "Экспериментальные функции", + "favorites": "Favorites", "favorites_page_no_favorites": "В избранном сейчас пусто", "favorites_page_title": "Избранное", "filename_search": "Имя или расширение файла", + "filter": "Фильтр", "haptic_feedback_switch": "Включить тактильную отдачу", "haptic_feedback_title": "Тактильная отдача", "header_settings_add_header_tip": "Добавить заголовок", @@ -255,13 +274,16 @@ "home_page_first_time_notice": "Если вы используете приложение впервые, убедитесь, что вы выбрали резервный(е) альбом(ы), чтобы временная шкала могла заполнить фотографии и видео в альбоме(ах).", "home_page_share_err_local": "Невозможно поделиться локальными данными по ссылке, пропуск...", "home_page_upload_err_limit": "Вы можете выгрузить максимум 30 файлов за раз", - "image_saved_successfully": "Image saved", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Изображение сохранено", "image_viewer_page_state_provider_download_error": "Ошибка загрузки", "image_viewer_page_state_provider_download_started": "Загрузка началась", "image_viewer_page_state_provider_download_success": "Успешно загружено", "image_viewer_page_state_provider_share_error": "Ошибка общего доступа", "invalid_date": "Неверная дата", "invalid_date_format": "Неверный формат даты", + "library": "Library", "library_page_albums": "Альбомы", "library_page_archive": "Архив", "library_page_device_albums": "Альбомы на устройстве", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Динамические фото", "multiselect_grid_edit_date_time_err_read_only": "Невозможно редактировать дату объектов только для чтения, пропуск...", "multiselect_grid_edit_gps_err_read_only": "Невозможно редактировать местоположение объектов только для чтения, пропуск...", + "my_albums": "My albums", "no_assets_to_show": "Объекты отсутствуют", "no_name": "Без имени", "notification_permission_dialog_cancel": "Отмена", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Предоставьте разрешение на включение уведомлений", "notification_permission_list_tile_enable_button": "Включить уведомления", "notification_permission_list_tile_title": "Разрешение на уведомление", + "on_this_device": "On this device", "partner_list_user_photos": "Фотографии {user}", "partner_list_view_all": "Посмотреть все", "partner_page_add_partner": "Добавить партнёра", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} больше не сможет получить доступ к вашим фотографиям", "partner_page_stop_sharing_title": "Закрыть доступ партнёра к вашим фото?", "partner_page_title": "Партнёр", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Назад", "permission_onboarding_continue_anyway": "Все равно продолжить", "permission_onboarding_get_started": "Давайте начнём", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Доступ получен! Всё готово.", "permission_onboarding_permission_limited": "Доступ к файлам ограничен. Чтобы Immich мог создавать резервные копии и управлять вашей галереей, пожалуйста, предоставьте приложению разрешение на доступ к \"Фото и видео\" в Настройках.", "permission_onboarding_request": "Immich просит вас предоставить разрешение на доступ к вашим фото и видео", + "places": "Places", "preferences_settings_title": "Параметры", "profile_drawer_app_logs": "Журнал", "profile_drawer_client_out_of_date_major": "Версия мобильного приложения устарела. Пожалуйста, обновитесь до последней основной версии.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Настройки", "profile_drawer_sign_out": "Выйти", "profile_drawer_trash": "Корзина", + "recently_added": "Recently added", "recently_added_page_title": "Недавно добавленные", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "Сохранить в галерею", "scaffold_body_error_occurred": "Возникла ошибка", + "search_albums": "Search albums", "search_bar_hint": "Поиск фотографий", "search_filter_apply": "Применить фильтр", "search_filter_camera": "Камера", @@ -398,8 +427,8 @@ "search_filter_display_option_archive": "Архив", "search_filter_display_option_favorite": "Избранное", "search_filter_display_option_not_in_album": "Не в альбоме", - "search_filter_display_options": "Параметри відображення", - "search_filter_display_options_title": "Параметри відображення", + "search_filter_display_options": "Настройки отображения", + "search_filter_display_options_title": "Настройки отображения", "search_filter_location": "Местоположение", "search_filter_location_city": "Город", "search_filter_location_country": "Страна", @@ -460,7 +489,7 @@ "setting_notifications_notify_seconds": "{} секунд", "setting_notifications_single_progress_subtitle": "Подробная информация о ходе загрузки для каждого объекта", "setting_notifications_single_progress_title": "Показать ход выполнения фонового резервного копирования", - "setting_notifications_subtitle": "Настройка параметров уведомлени", + "setting_notifications_subtitle": "Настройка параметров уведомлений", "setting_notifications_title": "Уведомления", "setting_notifications_total_progress_subtitle": "Общий прогресс загрузки (выполнено/всего объектов)", "setting_notifications_total_progress_title": "Показать общий прогресс фонового резервного копирования", @@ -478,7 +507,7 @@ "shared_album_activities_input_hint": "Скажите что-нибудь", "shared_album_activity_remove_content": "Хотите ли Вы удалить это сообщение?", "shared_album_activity_remove_title": "Удалить сообщение", - "shared_album_activity_setting_subtitle": "Разрешить другим отвечат", + "shared_album_activity_setting_subtitle": "Разрешить другим отвечать", "shared_album_activity_setting_title": "Комментарии и лайки", "shared_album_section_people_action_error": "Ошибка при выходе/удалении из альбома", "shared_album_section_people_action_leave": "Удалить пользователя из альбома", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Загрузить", "shared_link_manage_links": "Управление общими ссылками", "shared_link_public_album": "Публичный альбом", + "shared_links": "Shared links", "share_done": "Готово", + "shared_with_me": "Shared with me", "share_invite": "Пригласить в альбом", "sharing_page_album": "Общие альбомы", "sharing_page_description": "Создавайте общие альбомы, чтобы делиться фотографиями и видео с людьми в вашей сети.", @@ -550,7 +581,7 @@ "theme_setting_asset_list_storage_indicator_title": "Показать индикатор хранилища на плитках объектов", "theme_setting_asset_list_tiles_per_row_title": "Количество объектов в строке ({})", "theme_setting_colorful_interface_subtitle": "Применить основной цвет на поверхность фона.", - "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_colorful_interface_title": "Красочный интерфейс", "theme_setting_dark_mode_switch": "Тёмная тема", "theme_setting_image_viewer_quality_subtitle": "Настройка качества просмотра полноэкранных изображения", "theme_setting_image_viewer_quality_title": "Качество просмотра изображений", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Трехэтапная загрузка может повысить производительность загрузки, но вызывает значительно более высокую нагрузку на сеть", "theme_setting_three_stage_loading_title": "Включить трехэтапную загрузку", "translated_text_options": "Опции", + "trash": "Trash", "trash_emptied": "Корзина очищена", "trash_page_delete": "Удалить", "trash_page_delete_all": "Удалить все", @@ -570,7 +602,7 @@ "trash_page_empty_trash_dialog_content": "Вы хотите очистить свою корзину? Эти объекты будут навсегда удалены из Immich.", "trash_page_empty_trash_dialog_ok": "ОК", "trash_page_info": "Удаленные элементы будут окончательно удалены через {} дней", - "trash_page_no_assets": "Удаленные объекты отсутсвуют", + "trash_page_no_assets": "Удаленные объекты отсутствуют", "trash_page_restore": "Восстановить", "trash_page_restore_all": "Восстановить все", "trash_page_select_assets_btn": "Выбранные объекты", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "пожалуйста, найдите время, чтобы посетить", "version_announcement_overlay_text_3": " и убедитесь, что ваши настройки docker-compose и .env обновлены, чтобы предотвратить любые неправильные настройки, особенно если вы используете WatchTower или любой другой механизм, который обрабатывает обновление вашего серверного приложения автоматически.", "version_announcement_overlay_title": "Доступна новая версия сервера \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Удалить из стека", "viewer_stack_use_as_main_asset": "Использовать в качестве основного объекта", "viewer_unstack": "Разобрать стек" diff --git a/mobile/assets/i18n/sk-SK.json b/mobile/assets/i18n/sk-SK.json index 200db9e32038b3..eb4e304f2d1610 100644 --- a/mobile/assets/i18n/sk-SK.json +++ b/mobile/assets/i18n/sk-SK.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Aktualizovať", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Pridané do {album}", "add_to_album_bottom_sheet_already_exists": "Už v {album}", "advanced_settings_log_level_title": "Úroveň logovania: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Oprava chýb", "album_info_card_backup_album_excluded": "VYLÚČENÉ", "album_info_card_backup_album_included": "ZAHRNUTÉ", + "albums": "Albums", "album_thumbnail_card_item": "1 položka", "album_thumbnail_card_items": "{} položiek", "album_thumbnail_card_shared": "Zdieľané", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Odstrániť z albumu", "album_viewer_appbar_share_to": "Zdieľať s", "album_viewer_page_share_add_users": "Pridať používateľov", + "all": "All", "all_people_page_title": "Ľudia", "all_videos_page_title": "Videá", "app_bar_signout_dialog_content": "Skutočne sa chcete odhlásiť?", "app_bar_signout_dialog_ok": "Áno", "app_bar_signout_dialog_title": "Odhlásiť sa", + "archived": "Archived", "archive_page_no_archived_assets": "Žiadne archivované médiá", "archive_page_title": "Archív ({})", "asset_action_delete_err_read_only": "Nemožno vymazať položku len na čítanie, preskakujem", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Odarchivovať", "control_bottom_app_bar_unfavorite": "Odznačiť ako obľúbené", "control_bottom_app_bar_upload": "Nahrať", + "create_album": "Create album", "create_album_page_untitled": "Bez názvu", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Vytvoriť", "create_shared_album_page_share": "Zdieľať", "create_shared_album_page_share_add_assets": "Pridať položky", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Odstrániť zdieľaný odkaz", "description_input_hint_text": "Pridať popis...", "description_input_submit_error": "Chyba pri aktualizovaní popisu, zobrazte log pre viac detailov", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Dátum a čas", "edit_date_time_dialog_timezone": "Časové pásmo", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Povolenie experimentálnej mriežky fotografií", "experimental_settings_subtitle": "Používajte na vlastné riziko!", "experimental_settings_title": "Experimentálne", + "favorites": "Favorites", "favorites_page_no_favorites": "Žiadne obľúbené médiá", "favorites_page_title": "Obľúbené", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Povoliť hmatovú odozvu", "haptic_feedback_title": "Hmatová odozva", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Ak aplikáciu používate prvý krát, nezabudnite si vybrať zálohované albumy, aby sa na časovej osi mohli nachádzať fotografie a videá z vybraných albumoch.", "home_page_share_err_local": "Nemožno zdieľať lokálne médiá pomocou odkazu", "home_page_upload_err_limit": "Naraz môžete nahrať len 30 médií, preskakujem...", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Chyba sťahovania", "image_viewer_page_state_provider_download_started": "Sťahovanie sa začalo", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Chyba zdieľania", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albumy", "library_page_archive": "Archív", "library_page_device_albums": "Albumy v zariadení", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Pohyblivé fotky", "multiselect_grid_edit_date_time_err_read_only": "Nemožno upraviť dátum položky len na čítanie, preskakujem", "multiselect_grid_edit_gps_err_read_only": "Nemožno upraviť polohu položky len na čítanie, preskakujem", + "my_albums": "My albums", "no_assets_to_show": "Žiadne položky", "no_name": "No name", "notification_permission_dialog_cancel": "Zrušiť", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Udeľte oprávnenie k aktivácii oznámení.", "notification_permission_list_tile_enable_button": "Povoliť upozornenia", "notification_permission_list_tile_title": "Povolenie oznámení", + "on_this_device": "On this device", "partner_list_user_photos": "Fotky používateľa {user}", "partner_list_view_all": "Zobraziť všetky", "partner_page_add_partner": "Pridať partnera", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} už nebude mať prístup ku vašim fotkám.", "partner_page_stop_sharing_title": "Zastaviť zdieľanie vašich fotiek?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Späť", "permission_onboarding_continue_anyway": "Pokračovať aj tak", "permission_onboarding_get_started": "Začať", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Povolenie udelené! Všetko je nastavené.", "permission_onboarding_permission_limited": "Povolenie obmedzené. Ak chcete, aby Immich zálohoval a spravoval celú vašu zbierku galérie, udeľte v Nastaveniach povolenia na fotografie a videá.", "permission_onboarding_request": "Immich vyžaduje povolenie na prezeranie vašich fotografií a videí.", + "places": "Places", "preferences_settings_title": "Preferencie", "profile_drawer_app_logs": "Logy", "profile_drawer_client_out_of_date_major": "Mobilná aplikácia je zastaralá. Prosím aktualizujte na najnovšiu verziu.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Nastavenia", "profile_drawer_sign_out": "Odhlásiť sa", "profile_drawer_trash": "Kôš", + "recently_added": "Recently added", "recently_added_page_title": "Nedávno pridané", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Vyskytla sa chyba", + "search_albums": "Search albums", "search_bar_hint": "Prehľadajte svoje obrázky", "search_filter_apply": "Použiť filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Nahrať", "shared_link_manage_links": "Spravovať zdieľané odkazy", "shared_link_public_album": "Verejný album", + "shared_links": "Shared links", "share_done": "Hotovo", + "shared_with_me": "Shared with me", "share_invite": "Pozvať do albumu", "sharing_page_album": "Zdieľané albumy", "sharing_page_description": "Vytvárajte zdieľané albumy a zdieľajte fotografie a videá s ľuďmi vo vašej sieti.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Trojstupňové načítanie môže zvýšiť výkonnosť načítania, ale vedie k výrazne vyššiemu zaťaženiu siete.", "theme_setting_three_stage_loading_title": "Povolenie trojstupňového načítavania", "translated_text_options": "Nastavenia", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Vymazať", "trash_page_delete_all": "Vymazať všetky", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "nájdite si čas na návštevu ", "version_announcement_overlay_text_3": " a uistite sa, že vaša konfigurácia docker-compose a .env je aktuálna, aby ste predišli nesprávnej konfigurácii, najmä ak používate WatchTower alebo akýkoľvek mechanizmus, ktorý podporuje automatické aktualizácie serverových aplikácií.", "version_announcement_overlay_title": "K dispozícii je nová verzia servera \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Odstrániť zo zoskupenia", "viewer_stack_use_as_main_asset": "Použiť ako hlavnú fotku", "viewer_unstack": "Odskupiť" diff --git a/mobile/assets/i18n/sl-SI.json b/mobile/assets/i18n/sl-SI.json index 7871d65de9b905..1d7ef33a4ef424 100644 --- a/mobile/assets/i18n/sl-SI.json +++ b/mobile/assets/i18n/sl-SI.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Posodobi", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Dodano v {album}", "add_to_album_bottom_sheet_already_exists": "Že v {albumu}", "advanced_settings_log_level_title": "Nivo dnevnika: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Odpravljanje težav", "album_info_card_backup_album_excluded": "IZKLJUČENO", "album_info_card_backup_album_included": "VKLJUČENO", + "albums": "Albums", "album_thumbnail_card_item": "1 element", "album_thumbnail_card_items": "{} elementov", "album_thumbnail_card_shared": "· V skupni rabi", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Odstrani iz albuma", "album_viewer_appbar_share_to": "Deli z", "album_viewer_page_share_add_users": "Dodaj uporabnike", + "all": "All", "all_people_page_title": "Ljudje", "all_videos_page_title": "Videoposnetki", "app_bar_signout_dialog_content": "Ste prepričani, da se želite odjaviti?", "app_bar_signout_dialog_ok": "Da", "app_bar_signout_dialog_title": "Odjava", + "archived": "Archived", "archive_page_no_archived_assets": "Ni arhiviranih sredstev", "archive_page_title": "Arhiv ({})\n", "asset_action_delete_err_read_only": "Sredstev samo za branje ni mogoče izbrisati, preskočim\n", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Odstrani iz arhiva", "control_bottom_app_bar_unfavorite": "Odstrani iz priljubljeno", "control_bottom_app_bar_upload": "Naloži", + "create_album": "Create album", "create_album_page_untitled": "Brez naslova", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Ustvari", "create_shared_album_page_share": "Deli", "create_shared_album_page_share_add_assets": "DODAJ SREDSTVO", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Izbriši povezavo skupne rabe", "description_input_hint_text": "Dodaj opis ...", "description_input_submit_error": "Napaka pri posodabljanju opisa, preverite dnevnik za več podrobnosti", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Datum in ura", "edit_date_time_dialog_timezone": "Časovni pas", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Omogoči eksperimentalno mrežo fotografij", "experimental_settings_subtitle": "Uporabljajte na lastno odgovornost!", "experimental_settings_title": "Eksperimentalno", + "favorites": "Favorites", "favorites_page_no_favorites": "Ni priljubljenih sredstev", "favorites_page_title": "Priljubljene", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Uporabi haptičen odziv", "haptic_feedback_title": "Haptičen odziv", "header_settings_add_header_tip": "Dodaj glavo", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Če aplikacijo uporabljate prvič, se prepričajte, da ste izbrali rezervne albume, tako da lahko časovna premica zapolni fotografije in videoposnetke v albumih.", "home_page_share_err_local": "Lokalnih sredstev ni mogoče deliti prek povezave, preskakujem", "home_page_upload_err_limit": "Hkrati lahko naložite največ 30 sredstev, preskakujem", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Napaka pri prenosu", "image_viewer_page_state_provider_download_started": "Prenos se je začel", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Napaka skupne rabe", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albumi", "library_page_archive": "Arhiv", "library_page_device_albums": "Albumi v napravi", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Fotografije v gibanju", "multiselect_grid_edit_date_time_err_read_only": "Ni mogoče urediti datuma sredstev samo za branje, preskočim", "multiselect_grid_edit_gps_err_read_only": "Ni mogoče urediti lokacije sredstev samo za branje, preskočim", + "my_albums": "My albums", "no_assets_to_show": "Ni sredstev za prikaz", "no_name": "No name", "notification_permission_dialog_cancel": "Prekliči", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Izdaj dovoljenje za omogočanje obvestil.", "notification_permission_list_tile_enable_button": "Omogoči obvestila", "notification_permission_list_tile_title": "Dovoljenje za obvestila", + "on_this_device": "On this device", "partner_list_user_photos": "{user}ovih fotografij", "partner_list_view_all": "Poglej vse", "partner_page_add_partner": "Dodaj partnerja", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} ne bo imel več dostopa do vaših fotografij.", "partner_page_stop_sharing_title": "Želite prenehati deliti svoje fotografije?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Sredstev partnerja ni mogoče izbrisati, preskakujem", "permission_onboarding_continue_anyway": "Vseeno nadaljuj", "permission_onboarding_get_started": "Začnimo", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Dovoljenje je izdano! Vse je pripravljeno.", "permission_onboarding_permission_limited": "Dovoljenje je omejeno. Če želite Immichu dovoliti varnostno kopiranje in upravljanje vaše celotne zbirke galerij, v nastavitvah podelite dovoljenja za fotografije in videoposnetke.", "permission_onboarding_request": "Immich potrebuje dovoljenje za ogled vaših fotografij in videoposnetkov.", + "places": "Places", "preferences_settings_title": "Nastavitve", "profile_drawer_app_logs": "Dnevniki", "profile_drawer_client_out_of_date_major": "Mobilna aplikacija je zastarela. Posodobite na najnovejšo glavno različico.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Nastavitve", "profile_drawer_sign_out": "Odjava", "profile_drawer_trash": "Smetnjak", + "recently_added": "Recently added", "recently_added_page_title": "Nedavno dodano", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Prišlo je do napake", + "search_albums": "Search albums", "search_bar_hint": "Poišči svoje fotografije", "search_filter_apply": "Uporabi filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Naloži", "shared_link_manage_links": "Upravljanje povezav v skupni rabi", "shared_link_public_album": "Javni album", + "shared_links": "Shared links", "share_done": "Končano", + "shared_with_me": "Shared with me", "share_invite": "Povabi v album", "sharing_page_album": "Albumi v skupni rabi", "sharing_page_description": "Ustvarite albume za skupno rabo fotografij in videoposnetkov z osebami v vašem omrežju.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Tristopenjsko nalaganje lahko poveča zmogljivost nalaganja, vendar povzroči znatno večjo obremenitev omrežja", "theme_setting_three_stage_loading_title": "Omogoči tristopenjsko nalaganje", "translated_text_options": "Možnosti", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Izbriši", "trash_page_delete_all": "Izbriši vse", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "vzemi si čas in obišči", "version_announcement_overlay_text_3": "in zagotovite, da sta vaša nastavitev docker-compose in .env posodobljena, da preprečite morebitne napačne konfiguracije, zlasti če uporabljate WatchTower ali kateri koli mehanizem, ki samodejno posodablja vašo strežniško aplikacijo.", "version_announcement_overlay_title": "Na voljo je nova različica strežnika \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Odstrani iz sklada", "viewer_stack_use_as_main_asset": "Uporabi kot glavno sredstvo", "viewer_unstack": "Razkladi" diff --git a/mobile/assets/i18n/sr-Cyrl.json b/mobile/assets/i18n/sr-Cyrl.json index 324c9069fdf460..0075f65de0557f 100644 --- a/mobile/assets/i18n/sr-Cyrl.json +++ b/mobile/assets/i18n/sr-Cyrl.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" diff --git a/mobile/assets/i18n/sr-Latn.json b/mobile/assets/i18n/sr-Latn.json index 744ebe72ce63f6..3e11d73e08a7ee 100644 --- a/mobile/assets/i18n/sr-Latn.json +++ b/mobile/assets/i18n/sr-Latn.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Dodato u {album}", "add_to_album_bottom_sheet_already_exists": "Već u {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "ISKLJUČENO", "album_info_card_backup_album_included": "UKLJUČENO", + "albums": "Albums", "album_thumbnail_card_item": "1 stavka", "album_thumbnail_card_items": "{} stavki", "album_thumbnail_card_shared": "Deljeno", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Obriši iz albuma", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Dodaj korisnike", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Bez naslova", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Napravi", "create_shared_album_page_share": "Podeli", "create_shared_album_page_share_add_assets": "DODAJ ", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Aktiviraj eksperimentalni mrežni prikaz fotografija", "experimental_settings_subtitle": "Koristiti na sopstvenu odgovornost!", "experimental_settings_title": "Eksperimentalno", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Omiljeno", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Ako je ovo prvi put da koristite aplikaciju, molimo Vas da odaberete albume koje želite da sačuvate", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Preuzimanje Neuspešno", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albumi", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Odustani", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Dozvoli Notifikacije\n", "notification_permission_list_tile_enable_button": "Uključi Notifikacije", "notification_permission_list_tile_title": "Dozvole za notifikacije", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Evidencija", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Opcije", "profile_drawer_sign_out": "Odjavi se", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Pretražite Vaše fotografije", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Pozivnica za album", "sharing_page_album": "Deljeni albumi", "sharing_page_description": "Napravi deljene albume da deliš fotografije i video zapise sa ljudima na tvojoj mreži", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Trostepeno učitavanje možda ubrza učitavanje, po cenu potrošnje podataka", "theme_setting_three_stage_loading_title": "Aktiviraj trostepeno učitavanje", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "molimo Vas izdvojite vremena da pogledate", "version_announcement_overlay_text_3": "i proverite da su Vaš docker-compose i .env najnovije verzije da bi izbegli greške u radu. Pogotovu ako koristite WatchTower ili bilo koji drugi mehanizam koji automatski instalira nove verzije vaše serverske aplikacije.", "version_announcement_overlay_title": "Nova verzija servera je dostupna \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" diff --git a/mobile/assets/i18n/sv-FI.json b/mobile/assets/i18n/sv-FI.json index 324c9069fdf460..0075f65de0557f 100644 --- a/mobile/assets/i18n/sv-FI.json +++ b/mobile/assets/i18n/sv-FI.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" diff --git a/mobile/assets/i18n/sv-SE.json b/mobile/assets/i18n/sv-SE.json index 0d6c7a310855de..078f5780fc1afa 100644 --- a/mobile/assets/i18n/sv-SE.json +++ b/mobile/assets/i18n/sv-SE.json @@ -6,6 +6,7 @@ "action_common_save": "Spara", "action_common_select": "Välj", "action_common_update": "Uppdatera", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Tillagd till {album}", "add_to_album_bottom_sheet_already_exists": "Redan i {album}", "advanced_settings_log_level_title": "Loggnivå: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Felsökning", "album_info_card_backup_album_excluded": "EXKLUDERAD", "album_info_card_backup_album_included": "INKLUDERAD", + "albums": "Albums", "album_thumbnail_card_item": "1 objekt", "album_thumbnail_card_items": "{} objekt", "album_thumbnail_card_shared": " · Delad", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Ta bort från album", "album_viewer_appbar_share_to": "Dela Till", "album_viewer_page_share_add_users": "Lägg till användare", + "all": "All", "all_people_page_title": "Personer", "all_videos_page_title": "Videor", "app_bar_signout_dialog_content": "Är du säker på att du vill logga ut?", "app_bar_signout_dialog_ok": "Ja", "app_bar_signout_dialog_title": "Logga ut", + "archived": "Archived", "archive_page_no_archived_assets": "Inga arkiverade objekt hittade", "archive_page_title": "Arkiv ({})", "asset_action_delete_err_read_only": "Kan inte ta bort skrivskyddade objekt, hoppar över", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Avarkivera", "control_bottom_app_bar_unfavorite": "Avfavorisera", "control_bottom_app_bar_upload": "Ladda Upp", + "create_album": "Create album", "create_album_page_untitled": "Namnlös", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Skapa", "create_shared_album_page_share": "Dela", "create_shared_album_page_share_add_assets": "LÄGG TILL OBJEKT", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Ta Bort Delad Länk", "description_input_hint_text": "Lägg till beskrivning...", "description_input_submit_error": "Fel vid uppdatering av beskrivning, se loggen för fler detaljer", + "download_canceled": "Nedladdning avbruten", + "download_complete": "Nedladdning slutförd", + "download_enqueue": "Nedladdning köad", "download_error": "Fel vid nedladdning", + "download_failed": "Nedladdning misslyckades", + "download_filename": "fil: {}", + "download_finished": "Nedladdning klar", + "downloading": "Laddar ner...", + "downloading_media": "Laddar ner media", + "download_notfound": "Nedladdning kan inte hittas", + "download_paused": "Nedladdning pausad", "download_started": "Nedladdning påbörjad", "download_sucess": "Nedladdning lyckades", "download_sucess_android": "Media har laddats ner till DCIM/Immich", + "download_waiting_to_retry": "Väntar på omförsök", "edit_date_time_dialog_date_time": "Datum och Tid", "edit_date_time_dialog_timezone": "Tidszon", "edit_image_title": "Redigera", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Aktivera experimentellt fotorutnät", "experimental_settings_subtitle": "Använd på egen risk!", "experimental_settings_title": "Experimentellt", + "favorites": "Favorites", "favorites_page_no_favorites": "Inga favoritobjekt hittades", "favorites_page_title": "Favoriter", "filename_search": "Filnamn eller filändelse", + "filter": "Filter", "haptic_feedback_switch": "Aktivera haptisk feedback", "haptic_feedback_title": "Haptisk Feedback", "header_settings_add_header_tip": "Lägg Till Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Om det här är första gången du använder appen, välj ett eller flera backup-album så att tidslinjen kan fyllas med foton och videor från albumen.", "home_page_share_err_local": "Kan inte dela lokalt objekt via länk, hoppar över", "home_page_upload_err_limit": "Kan bara ladda upp max 30 objekt åt gången, hoppar över", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Bild sparad", "image_viewer_page_state_provider_download_error": "Fel Vid Nedladdning", "image_viewer_page_state_provider_download_started": "Nedladdning Påbörjad", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Delningsfel", "invalid_date": "Felaktigt datum", "invalid_date_format": "Felaktigt datumformat", + "library": "Library", "library_page_albums": "Album", "library_page_archive": "Arkiv", "library_page_device_albums": "Album på Enheten", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Rörelsefoton", "multiselect_grid_edit_date_time_err_read_only": "Kan inte ändra datum på skrivskyddade objekt, hoppar över", "multiselect_grid_edit_gps_err_read_only": "Kan inte ändra plats på skrivskyddade objekt, hoppar över", + "my_albums": "My albums", "no_assets_to_show": "Inga objekt att visa", "no_name": "Inget namn", "notification_permission_dialog_cancel": "Avbryt", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Tillåt rättighet för att slå på notiser.", "notification_permission_list_tile_enable_button": "Aktivera Notiser", "notification_permission_list_tile_title": "Notisrättighet", + "on_this_device": "On this device", "partner_list_user_photos": "{user}s foton", "partner_list_view_all": "Visa alla", "partner_page_add_partner": "Lägg till partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} kommer inte längre att komma åt dina foton.", "partner_page_stop_sharing_title": "Sluta dela dina foton?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Bakåt", "permission_onboarding_continue_anyway": "Fortsätt ändå", "permission_onboarding_get_started": "Kom igång", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Rättigheten beviljad! Du är klar.", "permission_onboarding_permission_limited": "Rättighet begränsad. För att låta Immich säkerhetskopiera och hantera hela ditt galleri, tillåt foto- och video-rättigheter i Inställningar.", "permission_onboarding_request": "Immich kräver tillstånd för att se dina foton och videor.", + "places": "Places", "preferences_settings_title": "Inställningar", "profile_drawer_app_logs": "Loggar", "profile_drawer_client_out_of_date_major": "Mobilappen är utdaterad. Uppdatera till senaste huvudversionen.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Inställningar", "profile_drawer_sign_out": "Logga ut", "profile_drawer_trash": "Papperskorg", + "recently_added": "Recently added", "recently_added_page_title": "Nyligen tillagda", "save_to_gallery": "Spara i galleri", "scaffold_body_error_occurred": "Fel uppstod", + "search_albums": "Search albums", "search_bar_hint": "Sök bland dina foton", "search_filter_apply": "Aktivera filter", "search_filter_camera": "Kamera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Ladda upp", "shared_link_manage_links": "Hantera Delade länkar", "shared_link_public_album": "Publikt album", + "shared_links": "Shared links", "share_done": "Klart", + "shared_with_me": "Shared with me", "share_invite": "Bjuder in till album", "sharing_page_album": "Delade album", "sharing_page_description": "Skapa delade album för att dela foton och video med personer i ditt nätverk.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Trestegsladdning kan öka prestandan, men kan också leda till signifikant högre nätverksbelastning", "theme_setting_three_stage_loading_title": "Aktivera trestegsladdning", "translated_text_options": "Val", + "trash": "Trash", "trash_emptied": "Tömd papperskorg", "trash_page_delete": "Ta Bort", "trash_page_delete_all": "Ta Bort Alla", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": ". Ta gärna din tid att besöka ", "version_announcement_overlay_text_3": " för att se till att din docker-compose och .env-fil är uppdaterad för att undvika felkonfiguration, speciellt om du använder WatchTower eller liknande mekanism som automatiskt uppdaterar din container", "version_announcement_overlay_title": "Ny server-version finns tillgänglig \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Ta bort från Stapeln", "viewer_stack_use_as_main_asset": "Använd som Huvudobjekt", "viewer_unstack": "Stapla Av" diff --git a/mobile/assets/i18n/th-TH.json b/mobile/assets/i18n/th-TH.json index c93b0a37cfa124..b6013ceed46852 100644 --- a/mobile/assets/i18n/th-TH.json +++ b/mobile/assets/i18n/th-TH.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "อัปเดต", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "เพิ่มไปยัง {album}", "add_to_album_bottom_sheet_already_exists": "อยู่ใน {album} อยู่แล้ว", "advanced_settings_log_level_title": "ระดับการ Log: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "แก้ไขปัญหา", "album_info_card_backup_album_excluded": "ถูกยกเว้น", "album_info_card_backup_album_included": "รวม", + "albums": "Albums", "album_thumbnail_card_item": "1 รายการ", "album_thumbnail_card_items": "{} รายการ", "album_thumbnail_card_shared": " · ถูกแชร์", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "ลบออกจากอัลบั้ม", "album_viewer_appbar_share_to": "แชร์ให้", "album_viewer_page_share_add_users": "เพิ่มผู้ใช้งาน", + "all": "All", "all_people_page_title": "ผู้คน", "all_videos_page_title": "วิดีโอ", "app_bar_signout_dialog_content": "คุณแน่ใจว่าอยากออกจากระบบ", "app_bar_signout_dialog_ok": "ใช่", "app_bar_signout_dialog_title": "ออกจากระบบ", + "archived": "Archived", "archive_page_no_archived_assets": "ไม่พบทรัพยากรในที่เก็บถาวร", "archive_page_title": "เก็บถาวร ({})", "asset_action_delete_err_read_only": "ไม่สามารถลบทรัพยากรแบบอ่านอย่างเดียวได้ กำลังข้าม", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "นำออกจากที่เก็บถาวร", "control_bottom_app_bar_unfavorite": "นำออกจากรายการโปรด", "control_bottom_app_bar_upload": "อัพโหลด", + "create_album": "Create album", "create_album_page_untitled": "ไม่มีชื่อ", + "create_new": "CREATE NEW", "create_shared_album_page_create": "สร้าง", "create_shared_album_page_share": "แชร์", "create_shared_album_page_share_add_assets": "เพิ่มทรัพยากร", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "ลบลิงก์ที่แชร์", "description_input_hint_text": "เพื่มรายละเอียด...", "description_input_submit_error": "อัพเดตรายละเอียดผิดพลาด ตรวจสอบ log เพื่อรายละเอียดเพิ่มเติม", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "วันและเวลา", "edit_date_time_dialog_timezone": "เขดเวลา", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "เปิดตารางรูปภาพที่กำลังทดลอง", "experimental_settings_subtitle": "ใช้ภายใต้ความเสี่ยงของคุณเอง!", "experimental_settings_title": "ทดลอง", + "favorites": "Favorites", "favorites_page_no_favorites": "ไม่พบทรัพยากรในรายการโปรด", "favorites_page_title": "รายการโปรด", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "เปิดการตอบสนองแบบสัมผัส", "haptic_feedback_title": "การตอบสนองแบบสัมผัส", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "ถ้าครั้งนี้เป็นครั้งแรกที่ใช้แอปนี้ กรุณาเลือกอัลบั้มที่จะสำรองข้อมูล ไทม์ไลน์จะได้เพิ่มรูปภาพและวิดีโอที่อยู่ในอัลบั้ม", "home_page_share_err_local": "ไม่สามารถแชร์ผ่านลิงค์ได้ กำลังข้าม", "home_page_upload_err_limit": "สามารถอัพโหลดได้มากสุดครั้งละ 30 ทรัพยากร กำลังข้าม", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "ดาวน์โหลดผิดพลาด", "image_viewer_page_state_provider_download_started": "ดาวน์โหลดเริ่มต้น", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "แชร์ผิดพลาด", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "อัลบั้ม", "library_page_archive": "เก็บถาวร", "library_page_device_albums": "อัลบั้มบนเครื่อง", @@ -342,6 +364,7 @@ "motion_photos_page_title": "ภาพเคลื่อนไหว", "multiselect_grid_edit_date_time_err_read_only": "ไม่สามารถแก้ไขวันที่ทรัพยากรแบบอ่านอย่างเดียว กำลังข้าม", "multiselect_grid_edit_gps_err_read_only": "ไม่สามารถแก้ตำแหน่งของทรัพยากรแบบอ่านอย่างเดียว กำลังข้าม", + "my_albums": "My albums", "no_assets_to_show": "ไม่มีทรัพยากรให้แสดง", "no_name": "No name", "notification_permission_dialog_cancel": "ยกเลิก", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "อนุญาตการแจ้งเตือน", "notification_permission_list_tile_enable_button": "เปิดการแจ้งเดือน", "notification_permission_list_tile_title": "สิทธิ์การแจ้งเตือน", + "on_this_device": "On this device", "partner_list_user_photos": "รูปภาพของ {user}", "partner_list_view_all": "ดูทั้งหมด", "partner_page_add_partner": "เพิ่มพันธมิตร", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} จะไม่สามารถเข้าถึงรูปภาพของคุณ", "partner_page_stop_sharing_title": "หยุดแชร์รูปภาพ?", "partner_page_title": "พันธมิตร", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "กลับ", "permission_onboarding_continue_anyway": "ดำเนินการต่อ", "permission_onboarding_get_started": "เริ่มต้น", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "ให้สิทธิ์สำเร็จ คุณพร้อมใช้งานแล้ว", "permission_onboarding_permission_limited": "สิทธ์จำกัด เพื่อให้ Immich สำรองข้อมูลและจัดการคลังภาพได้ ตั้งค่าสิทธิเข้าถึงรูปภาพและวิดีโอ", "permission_onboarding_request": "Immich จำเป็นจะต้องได้รับสิทธิ์ดูรูปภาพและวิดีโอ", + "places": "Places", "preferences_settings_title": "การตั้งค่า", "profile_drawer_app_logs": "การบันทึก", "profile_drawer_client_out_of_date_major": "แอปพลิเคชันมีอัพเดต โปรดอัปเดตเป็นเวอร์ชันหลักล่าสุด", @@ -383,9 +410,11 @@ "profile_drawer_settings": "ตั้งค่า", "profile_drawer_sign_out": "ออกจากระบบ", "profile_drawer_trash": "ขยะ", + "recently_added": "Recently added", "recently_added_page_title": "เพิ่มล่าสุด", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "เกิดข้อผิดพลาด", + "search_albums": "Search albums", "search_bar_hint": "ค้นหารูปภาพของคุณ", "search_filter_apply": "บันทึกตัวกรอง", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "อัพโหลด", "shared_link_manage_links": "บริหารลิงก์", "shared_link_public_album": "อัลบั้มสาธารณะ", + "shared_links": "Shared links", "share_done": "เสร็จ", + "shared_with_me": "Shared with me", "share_invite": "เชิญเข้าอัลบั้ม", "sharing_page_album": "อัลบั้มที่แชร์", "sharing_page_description": "สร้างอัลบั้มที่แชร์เพื่อแชร์รูปภาพและวิดีโอให้กับคนบนเครือข่ายคุณ", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "การโหลดแบบสามขั้นตอนอาจเพิ่มประสิทธิภาพในการโหลดแต่จะทำให้โหลดเครื่อข่ายเพิ่มขึ้นมาก", "theme_setting_three_stage_loading_title": "เปิดการโหลดสามขั้นตอน", "translated_text_options": "ตัวเลือก", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "ลบ", "trash_page_delete_all": "ลบทั้งหมด", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "กรุณาใช้เวลาดู", "version_announcement_overlay_text_3": "และรับรองว่าการติดตั้ง docker-compose และ .env เป็นปัจจุบันเพื่อไม่ให้เกิดการติดตั้งผิดพลาด โดยเฉพาะผู้ใช้ WatchTower หรือระบบอัพเดตแอปพลิเคชั่นเซิร์ฟเวอร์อัตโนมัติ", "version_announcement_overlay_title": "มีเวอร์ชันใหม่สำหรับเซิร์ฟเวอร์ \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "เอาออกจากที่ซ้อน", "viewer_stack_use_as_main_asset": "ใช้เป็นทรัพยากรหลัก", "viewer_unstack": "หยุดซ้อน" diff --git a/mobile/assets/i18n/uk-UA.json b/mobile/assets/i18n/uk-UA.json index f3b2b0ba5f4b46..8bdd9aeaf23395 100644 --- a/mobile/assets/i18n/uk-UA.json +++ b/mobile/assets/i18n/uk-UA.json @@ -6,12 +6,13 @@ "action_common_save": "Зберегти", "action_common_select": "Вибрати", "action_common_update": "Оновити", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Додати до {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}", "advanced_settings_log_level_title": "Log level: {}", "advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.", "advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням", - "advanced_settings_proxy_headers_subtitle": "Определите заголовки прокси-сервера, которые Immich должен отправлять с каждым сетевым запросом.", + "advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.", "advanced_settings_proxy_headers_title": "Проксі-заголовки", "advanced_settings_self_signed_ssl_subtitle": "Пропускає перевірку SSL-сертифіката сервера. Потрібне для самопідписаних сертифікатів.", "advanced_settings_self_signed_ssl_title": "Дозволити самопідписані SSL-сертифікати", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Усунення несправностей", "album_info_card_backup_album_excluded": "ВИЛУЧЕНИЙ", "album_info_card_backup_album_included": "ВКЛЮЧЕНИЙ", + "albums": "Albums", "album_thumbnail_card_item": "1 елемент", "album_thumbnail_card_items": "{} елементів", "album_thumbnail_card_shared": " · Спільний", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Видалити з альбому", "album_viewer_appbar_share_to": "Поділитися", "album_viewer_page_share_add_users": "Додати користувачів", + "all": "All", "all_people_page_title": "Люди", "all_videos_page_title": "Відео", "app_bar_signout_dialog_content": "Ви впевнені, що бажаєте вийти з аккаунта?", "app_bar_signout_dialog_ok": "Так", "app_bar_signout_dialog_title": "Вийти з аккаунта", + "archived": "Archived", "archive_page_no_archived_assets": "Немає архівних елементів", "archive_page_title": "Архів ({})", "asset_action_delete_err_read_only": "Неможливо видалити елемент(и) лише для читання, пропущено", @@ -185,12 +189,14 @@ "control_bottom_app_bar_unarchive": "Розархівувати", "control_bottom_app_bar_unfavorite": "Видалити з улюблених", "control_bottom_app_bar_upload": "Завантажити", + "create_album": "Create album", "create_album_page_untitled": "Без назви", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Створити", "create_shared_album_page_share": "Поділитися", "create_shared_album_page_share_add_assets": "ДОДАТИ ЕЛЕМЕНТИ", "create_shared_album_page_share_select_photos": "Вибрати Знімки", - "crop": "Crop", + "crop": "Кадрувати", "curated_location_page_title": "Місця", "curated_object_page_title": "Речі", "daily_title_text_date": "E, MMM dd", @@ -210,15 +216,26 @@ "delete_shared_link_dialog_title": "Видалити спільне посилання", "description_input_hint_text": "Додати опис...", "description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "\nЗавантаження скасовано", + "download_complete": "\nЗавантаження закінчено", + "download_enqueue": "Завантаження поставлено в чергу", + "download_error": "Помилка завантаження", + "download_failed": "Завантаження не вдалося", + "download_filename": "файл: {}", + "download_finished": "Завантаження закінчено", + "downloading": "Завантаження...", + "downloading_media": "Завантаження медіа", + "download_notfound": "Завантаження не виявлено", + "download_paused": "\nЗавантаження призупинено", + "download_started": "Завантаження розпочато", + "download_sucess": "Успішне завантаження", + "download_sucess_android": "Медіафайли завантажено в DCIM/Immich", + "download_waiting_to_retry": "Очікування повторної спроби", "edit_date_time_dialog_date_time": "Дата і час", "edit_date_time_dialog_timezone": "Часовий пояс", - "edit_image_title": "Edit", + "edit_image_title": "Редагувати", "edit_location_dialog_title": "Місцезнаходження", - "error_saving_image": "Error: {}", + "error_saving_image": "Помилка: {}", "exif_bottom_sheet_description": "Додати опис...", "exif_bottom_sheet_details": "ПОДРОБИЦІ", "exif_bottom_sheet_location": "МІСЦЕ", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Експериментальний макет знімків", "experimental_settings_subtitle": "На власний ризик!", "experimental_settings_title": "Експериментальні", + "favorites": "Favorites", "favorites_page_no_favorites": "Немає улюблених елементів", "favorites_page_title": "Улюблені", "filename_search": "Ім'я або розширення файлу", + "filter": "Фільтр", "haptic_feedback_switch": "Увімкнути тактильну віддачу", "haptic_feedback_title": "Тактильна віддача", "header_settings_add_header_tip": "Додати заголовок", @@ -255,13 +274,16 @@ "home_page_first_time_notice": "Якщо ви вперше користуєтеся програмою, переконайтеся, що ви вибрали альбоми для резервування, щоб могти заповнювати хронологію знімків та відео в альбомах.", "home_page_share_err_local": "Неможливо поділитися локальними елементами через посилання, пропущено", "home_page_upload_err_limit": "Можна вантажити не більше 30 елементів водночас, пропущено", - "image_saved_successfully": "Image saved", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "image_saved_successfully": "Зображення збережено", "image_viewer_page_state_provider_download_error": "Помилка завантаження", "image_viewer_page_state_provider_download_started": "Завантаження почалося", "image_viewer_page_state_provider_download_success": "Усіпшно завантажено", "image_viewer_page_state_provider_share_error": "Помилка спільного доступу", "invalid_date": "Недійсна дата", "invalid_date_format": "Недійсний формат дати", + "library": "Library", "library_page_albums": "Альбоми", "library_page_archive": "Архів", "library_page_device_albums": "Альбоми на пристрої", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Рухомі Знімки", "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено", "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено", + "my_albums": "My albums", "no_assets_to_show": "Елементи відсутні", "no_name": "Без імені", "notification_permission_dialog_cancel": "Скасувати", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Надати дозвіл для сповіщень.", "notification_permission_list_tile_enable_button": "Увімкнути Сповіщення", "notification_permission_list_tile_title": "Дозвіл на Сповіщення", + "on_this_device": "On this device", "partner_list_user_photos": "Фотографії {user}", "partner_list_view_all": "Переглянути усі", "partner_page_add_partner": "Додати партнера", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} втратить доступ до ваших знімків.", "partner_page_stop_sharing_title": "Припинити надання ваших знімків?", "partner_page_title": "Партнер", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Назад", "permission_onboarding_continue_anyway": "Все одно продовжити", "permission_onboarding_get_started": "Розпочати", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Доступ надано! Все готово.", "permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях", "permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.", + "places": "Places", "preferences_settings_title": "Параметри", "profile_drawer_app_logs": "Журнал", "profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Налаштування", "profile_drawer_sign_out": "Вийти", "profile_drawer_trash": "Кошик", + "recently_added": "Recently added", "recently_added_page_title": "Нещодавні", - "save_to_gallery": "Save to gallery", + "save_to_gallery": "Зберегти в галерею", "scaffold_body_error_occurred": "Виникла помилка", + "search_albums": "Search albums", "search_bar_hint": "Шукати ваші знімки", "search_filter_apply": "Застосувати фільтр", "search_filter_camera": "Камера", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Завантажити", "shared_link_manage_links": "Керування спільними посиланнями", "shared_link_public_album": "Публічний альбом", + "shared_links": "Shared links", "share_done": "Готово", + "shared_with_me": "Shared with me", "share_invite": "Запросити в альбом", "sharing_page_album": "Спільні альбоми", "sharing_page_description": "Створюйте спільні альбоми, щоб ділитися знімками та відео з людьми у вашій мережі.", @@ -550,7 +581,7 @@ "theme_setting_asset_list_storage_indicator_title": "Показувати піктограму сховища на плитках елементів", "theme_setting_asset_list_tiles_per_row_title": "Кількість елементів у рядку ({})", "theme_setting_colorful_interface_subtitle": "Застосувати основний колір на поверхню фону.", - "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_colorful_interface_title": "Барвистий інтерфейс", "theme_setting_dark_mode_switch": "Темна тема", "theme_setting_image_viewer_quality_subtitle": "Налаштування якості перегляду повноекранних зображень", "theme_setting_image_viewer_quality_title": "Якість перегляду зображень", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Триетапне завантаження може підвищити продуктивність завантаження, але спричинить значно більше навантаження на мережу", "theme_setting_three_stage_loading_title": "Увімкнути триетапне завантаження", "translated_text_options": "Налаштування", + "trash": "Trash", "trash_emptied": "Кошик очищений", "trash_page_delete": "Видалити", "trash_page_delete_all": "Видалити усі", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "знайдіть хвильку навідатися на ", "version_announcement_overlay_text_3": "і переконайтеся, що ваші налаштування docker-compose та .env оновлені, аби запобігти будь-якій неправильній конфігурації, особливо, якщо ви використовуєте WatchTower або інший механізм, для автоматичних оновлень вашої серверної частини.", "version_announcement_overlay_title": "Доступна нова версія сервера \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Видалити зі стеку", "viewer_stack_use_as_main_asset": "Використовувати як основний елементи", "viewer_unstack": "Розібрати стек" diff --git a/mobile/assets/i18n/vi-VN.json b/mobile/assets/i18n/vi-VN.json index 6cd2a080e47131..c77f9427f13091 100644 --- a/mobile/assets/i18n/vi-VN.json +++ b/mobile/assets/i18n/vi-VN.json @@ -6,6 +6,7 @@ "action_common_save": "Lưu", "action_common_select": "Chọn", "action_common_update": "Cập nhật", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Thêm vào {album}", "add_to_album_bottom_sheet_already_exists": "Đã có sẵn trong {album}", "advanced_settings_log_level_title": "Phân loại nhật ký: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Xử lý sự cố", "album_info_card_backup_album_excluded": "ĐÃ BỎ QUA", "album_info_card_backup_album_included": "ĐÃ THÊM", + "albums": "Albums", "album_thumbnail_card_item": "1 mục", "album_thumbnail_card_items": "{} mục", "album_thumbnail_card_shared": " · Chia sẻ", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Xoá khỏi album", "album_viewer_appbar_share_to": "Chia sẻ với", "album_viewer_page_share_add_users": "Thêm người dùng", + "all": "All", "all_people_page_title": "Mọi người", "all_videos_page_title": "Video", "app_bar_signout_dialog_content": "Bạn có muốn đăng xuất?", "app_bar_signout_dialog_ok": "Có", "app_bar_signout_dialog_title": "Đăng xuất", + "archived": "Archived", "archive_page_no_archived_assets": "Không tìm thấy ảnh đã lưu trữ", "archive_page_title": "Kho lưu trữ ({})", "asset_action_delete_err_read_only": "Không thể xoá ảnh chỉ có quyền đọc, bỏ qua", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Huỷ lưu trữ", "control_bottom_app_bar_unfavorite": "Bỏ yêu thích", "control_bottom_app_bar_upload": "Tải lên", + "create_album": "Create album", "create_album_page_untitled": "Không tiêu đề", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Tạo", "create_shared_album_page_share": "Chia sẻ", "create_shared_album_page_share_add_assets": "THÊM ẢNH", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Xoá liên kết đã chia sẻ", "description_input_hint_text": "Thêm mô tả...", "description_input_submit_error": "Cập nhật mô tả không thành công, vui lòng kiểm tra nhật ký để biết thêm chi tiết", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_canceled": "Đã hủy tải xuống", + "download_complete": "Tải xuống hoàn tất", + "download_enqueue": "Đang chờ tải xuống", + "download_error": "Lỗi tải xuống", + "download_failed": "Tải xuống thất bại", + "download_filename": "tập tin: {}", + "download_finished": "Tải xuống hoàn tất", + "downloading": "Đang tải xuống...", + "downloading_media": "Đang tải xuống phương tiện", + "download_notfound": "Không tìm thấy tải xuống", + "download_paused": "Đã tạm dừng tải xuống", + "download_started": "Đã bắt đầu tải xuống", + "download_sucess": "Tải xuống thành công", + "download_sucess_android": "Phương tiện đã được tải vào DCIM/Immich", + "download_waiting_to_retry": "Đang chờ thử lại", "edit_date_time_dialog_date_time": "Ngày và Giờ", "edit_date_time_dialog_timezone": "Múi giờ", "edit_image_title": "Sửa", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Bật lưới ảnh thử nghiệm", "experimental_settings_subtitle": "Sử dụng có thể rủi ro!", "experimental_settings_title": "Chưa hoàn thiện", + "favorites": "Favorites", "favorites_page_no_favorites": "Không tìm thấy ảnh yêu thích", "favorites_page_title": "Ảnh yêu thích", "filename_search": "Tên hoặc phần mở rộng tập tin", + "filter": "Bộ lọc", "haptic_feedback_switch": "Bật phản hồi haptic\n", "haptic_feedback_title": "Haptic Feedback\n", "header_settings_add_header_tip": "Thêm Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "Nếu đây là lần đầu bạn sử dụng ứng dụng, đảm bảo chọn (các) album sao lưu để dòng thời gian có thể tự động thêm ảnh và video trong (các) album.\n", "home_page_share_err_local": "Không thể chia sẻ ảnh cục bộ qua liên kết, bỏ qua", "home_page_upload_err_limit": "Chỉ có thể tải lên tối đa 30 ảnh cùng một lúc, bỏ qua", + "ignore_icloud_photos": "Bỏ qua ảnh iCloud", + "ignore_icloud_photos_description": "Ảnh được lưu trữ trên iCloud sẽ không được tải lên máy chủ Immich", "image_saved_successfully": "Đã lưu ảnh", "image_viewer_page_state_provider_download_error": "Tải xuống không thành công", "image_viewer_page_state_provider_download_started": "Đã bắt đầu tải xuống", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Chia sẻ không thành công", "invalid_date": "Ngày không hợp lệ", "invalid_date_format": "Định dạng ngày không hợp lệ", + "library": "Library", "library_page_albums": "Album", "library_page_archive": "Kho lưu trữ", "library_page_device_albums": "Album trên thiết bị", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Ảnh động", "multiselect_grid_edit_date_time_err_read_only": "Không thể chỉnh sửa ngày của ảnh chỉ có quyền đọc, bỏ qua", "multiselect_grid_edit_gps_err_read_only": "Không thể chỉnh sửa vị trí của ảnh chỉ có quyền đọc, bỏ qua", + "my_albums": "My albums", "no_assets_to_show": "Không có mục nào để hiển thị", "no_name": "Không có tên", "notification_permission_dialog_cancel": "Từ chối", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Cấp quyền để bật thông báo", "notification_permission_list_tile_enable_button": "Bật thông báo", "notification_permission_list_tile_title": "Quyền thông báo", + "on_this_device": "On this device", "partner_list_user_photos": "Ảnh của {user}", "partner_list_view_all": "Xem tất cả", "partner_page_add_partner": "Thêm người thân", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} sẽ không thể truy cập ảnh của bạn.", "partner_page_stop_sharing_title": "Ngừng chia sẻ ảnh của bạn?", "partner_page_title": "Người thân", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Quay lại", "permission_onboarding_continue_anyway": "Vẫn tiếp tục", "permission_onboarding_get_started": "Bắt đầu", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Cấp quyền hoàn tất!", "permission_onboarding_permission_limited": "Quyền truy cập vào ảnh của bạn bị hạn chế. Để Immich sao lưu và quản lý toàn bộ thư viện ảnh của bạn, hãy cấp quyền truy cập toàn bộ ảnh trong Cài đặt.", "permission_onboarding_request": "Immich cần quyền để xem ảnh và video của bạn", + "places": "Places", "preferences_settings_title": "Tuỳ chỉnh", "profile_drawer_app_logs": "Nhật ký", "profile_drawer_client_out_of_date_major": "Ứng dụng đã lỗi thời. Vui lòng cập nhật lên phiên bản chính mới nhất.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Cài đặt", "profile_drawer_sign_out": "Đăng xuất", "profile_drawer_trash": "Thùng rác", + "recently_added": "Recently added", "recently_added_page_title": "Mới thêm gần đây", "save_to_gallery": "Lưu vào thư viện", "scaffold_body_error_occurred": "Xảy ra lỗi", + "search_albums": "Search albums", "search_bar_hint": "Tìm kiếm ảnh của bạn", "search_filter_apply": "Áp dụng bộ lọc", "search_filter_camera": "Máy ảnh", @@ -486,7 +515,7 @@ "shared_album_section_people_owner_label": "Chủ sở hữu", "shared_album_section_people_title": "MỌI NGƯỜI", "share_dialog_preparing": "Đang xử lý...", - "shared_link_app_bar_title": "Đường liên kết chia sẻ", + "shared_link_app_bar_title": "Liên kết chia sẻ", "shared_link_clipboard_copied_massage": "Đã sao chép tới bản ghi tạm", "shared_link_clipboard_text": "Liên kết: {}\nMật khẩu: {}", "shared_link_create_app_bar_title": "Tạo liên kết để chia sẻ", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Tải lên", "shared_link_manage_links": "Quản lý liên kết được chia sẻ", "shared_link_public_album": "Album công khai", + "shared_links": "Shared links", "share_done": "Hoàn tất", + "shared_with_me": "Shared with me", "share_invite": "Mời vào album", "sharing_page_album": "Album chia sẻ", "sharing_page_description": "Tạo album chia sẻ để chia sẻ ảnh và video với những người trong mạng của bạn.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Tải ba giai doạn có thể tăng hiệu năng tải ảnh nhưng sẽ tốn dữ liệu mạng đáng kể.", "theme_setting_three_stage_loading_title": "Bật tải ba giai đoạn", "translated_text_options": "Tuỳ chỉnh", + "trash": "Trash", "trash_emptied": "Đã dọn sạch thùng rác", "trash_page_delete": "Xoá", "trash_page_delete_all": "Xoá tất cả", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "vui lòng dành thời gian của bạn để đến thăm", "version_announcement_overlay_text_3": "và đảm bảo cài đặt docker-compose và tệp .env của bạn đã cập nhật để tránh bất kỳ cấu hình sai sót, đặc biệt nếu bạn dùng WatchTower hoặc bất kỳ cơ chế nào xử lý việc cập nhật ứng dụng máy chủ của bạn tự động.", "version_announcement_overlay_title": "Phiên bản máy chủ có bản cập nhật mới", + "videos": "Videos", "viewer_remove_from_stack": "Xoá khỏi nhóm", "viewer_stack_use_as_main_asset": "Đặt làm lựa chọn hàng đầu", "viewer_unstack": "Huỷ xếp nhóm" diff --git a/mobile/assets/i18n/zh-CN.json b/mobile/assets/i18n/zh-CN.json index d4e7f0406e3aac..0da7c3b2db4c3d 100644 --- a/mobile/assets/i18n/zh-CN.json +++ b/mobile/assets/i18n/zh-CN.json @@ -6,6 +6,7 @@ "action_common_save": "保存", "action_common_select": "选择", "action_common_update": "更新", + "add_a_name": "添加姓名", "add_to_album_bottom_sheet_added": "添加到 {album}", "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", "advanced_settings_log_level_title": "日志等级:{}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "故障排除", "album_info_card_backup_album_excluded": "已排除", "album_info_card_backup_album_included": "已选中", + "albums": "相册", "album_thumbnail_card_item": "1 项", "album_thumbnail_card_items": "{} 项", "album_thumbnail_card_shared": " · 已共享", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "从相册中移除", "album_viewer_appbar_share_to": "共享给", "album_viewer_page_share_add_users": "创建用户", + "all": "所有", "all_people_page_title": "人物", "all_videos_page_title": "视频", "app_bar_signout_dialog_content": "您确定要退出吗?", "app_bar_signout_dialog_ok": "是", "app_bar_signout_dialog_title": "退出登录", + "archived": "已存档", "archive_page_no_archived_assets": "未找到归档项目", "archive_page_title": "归档({})", "asset_action_delete_err_read_only": "无法删除只读项目,跳过", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "取消归档", "control_bottom_app_bar_unfavorite": "取消收藏", "control_bottom_app_bar_upload": "上传", + "create_album": "创建相册", "create_album_page_untitled": "未命名", + "create_new": "新建", "create_shared_album_page_create": "创建", "create_shared_album_page_share": "共享", "create_shared_album_page_share_add_assets": "添加项目", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "删除共享链接", "description_input_hint_text": "添加描述...", "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "download_canceled": "下载已取消", + "download_complete": "下载完成", + "download_enqueue": "已加入下载队列", "download_error": "下载出错", + "download_failed": "下载失败", + "download_filename": "文件:{}", + "download_finished": "下载完成", + "downloading": "下载中...", + "downloading_media": "正在下载媒体", + "download_notfound": "无法找到下载", + "download_paused": "下载已暂停", "download_started": "开始下载", "download_sucess": "下载成功", "download_sucess_android": "媒体已下载至 DCIM/Immich", + "download_waiting_to_retry": "等待重试", "edit_date_time_dialog_date_time": "日期和时间", "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "启用实验性照片网格", "experimental_settings_subtitle": "使用风险自负!", "experimental_settings_title": "实验性功能", + "favorites": "收藏", "favorites_page_no_favorites": "未找到收藏项目", "favorites_page_title": "收藏", "filename_search": "文件名或扩展名", + "filter": "筛选", "haptic_feedback_switch": "启用振动反馈", "haptic_feedback_title": "振动反馈", "header_settings_add_header_tip": "添加标头", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "如果这是您第一次使用该应用程序,请确保选择一个要备份的本地相册,以便可以在时间线中预览该相册中的照片和视频。", "home_page_share_err_local": "暂无法通过链接共享本地项目,跳过", "home_page_upload_err_limit": "一次最多只能上传 30 个项目,跳过", + "ignore_icloud_photos": "忽略iCloud照片", + "ignore_icloud_photos_description": "存储在iCloud中的照片不会上传至Immich服务器", "image_saved_successfully": "图片已保存", "image_viewer_page_state_provider_download_error": "下载出现错误", "image_viewer_page_state_provider_download_started": "下载启动", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "共享出错", "invalid_date": "无效的日期", "invalid_date_format": "无效的日期格式", + "library": "库", "library_page_albums": "相册", "library_page_archive": "归档", "library_page_device_albums": "设备上的相册", @@ -342,6 +364,7 @@ "motion_photos_page_title": "动图", "multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过", "multiselect_grid_edit_gps_err_read_only": "无法编辑只读项目的位置信息,跳过", + "my_albums": "我的相册", "no_assets_to_show": "无项目展示", "no_name": "无姓名", "notification_permission_dialog_cancel": "取消", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "授予通知权限。", "notification_permission_list_tile_enable_button": "启用通知", "notification_permission_list_tile_title": "通知权限", + "on_this_device": "在此设备", "partner_list_user_photos": "{user}的照片", "partner_list_view_all": "展示全部", "partner_page_add_partner": "添加同伴", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} 将无法再访问您的照片。", "partner_page_stop_sharing_title": "您确定要停止共享您的照片吗?", "partner_page_title": "同伴", + "partners": "伙伴", + "people": "人物", "permission_onboarding_back": "返回", "permission_onboarding_continue_anyway": "仍然继续", "permission_onboarding_get_started": "开始使用", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "已授权!一切就绪。", "permission_onboarding_permission_limited": "权限受限:要让 Immich 备份和管理您的整个图库收藏,请在“设置”中授予照片和视频权限。", "permission_onboarding_request": "Immich 需要权限才能查看您的照片和视频。", + "places": "地点", "preferences_settings_title": "偏好设置", "profile_drawer_app_logs": "日志", "profile_drawer_client_out_of_date_major": "客户端有大版本升级,请尽快升级至最新版。", @@ -383,9 +410,11 @@ "profile_drawer_settings": "设置", "profile_drawer_sign_out": "退出登录", "profile_drawer_trash": "回收站", + "recently_added": "近期添加", "recently_added_page_title": "最近添加", "save_to_gallery": "保存到图库", "scaffold_body_error_occurred": "发生错误", + "search_albums": "搜索相册", "search_bar_hint": "搜索照片", "search_filter_apply": "应用筛选", "search_filter_camera": "相机", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "更新", "shared_link_manage_links": "管理共享链接", "shared_link_public_album": "公共相册", + "shared_links": "共享链接", "share_done": "完成", + "shared_with_me": "共享给我", "share_invite": "邀请到共享相册", "sharing_page_album": "共享相册", "sharing_page_description": "创建共享相册以与网络中的人共享照片和视频。", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "三段式加载可能会提升加载性能,但可能会导致更高的网络负载", "theme_setting_three_stage_loading_title": "启用三段式加载", "translated_text_options": "选项", + "trash": "回收站", "trash_emptied": "空回收站", "trash_page_delete": "删除", "trash_page_delete_all": "删除全部", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "请花点时间访问", "version_announcement_overlay_text_3": "并检查您的 docker-compose 和 .env 是否为最新且正确的配置,特别是您在使用 WatchTower 或者其他自动更新的程序时,您需要更加细致的检查。", "version_announcement_overlay_title": "服务端有新版本啦 \uD83C\uDF89", + "videos": "视频", "viewer_remove_from_stack": "从堆叠中移除", "viewer_stack_use_as_main_asset": "作为主项目使用", "viewer_unstack": "取消堆叠" diff --git a/mobile/assets/i18n/zh-Hans.json b/mobile/assets/i18n/zh-Hans.json index f5ec6ab2a1bc8e..21a7fc2e4e67bf 100644 --- a/mobile/assets/i18n/zh-Hans.json +++ b/mobile/assets/i18n/zh-Hans.json @@ -6,6 +6,7 @@ "action_common_save": "保存", "action_common_select": "选择", "action_common_update": "更新", + "add_a_name": "添加姓名", "add_to_album_bottom_sheet_added": "添加到 {album}", "add_to_album_bottom_sheet_already_exists": "已在 {album} 中", "advanced_settings_log_level_title": "日志等级:{}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "故障排除", "album_info_card_backup_album_excluded": "已排除", "album_info_card_backup_album_included": "已选中", + "albums": "相册", "album_thumbnail_card_item": "1 项", "album_thumbnail_card_items": "{} 项", "album_thumbnail_card_shared": " · 已共享", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "从相册中移除", "album_viewer_appbar_share_to": "共享给", "album_viewer_page_share_add_users": "创建用户", + "all": "所有", "all_people_page_title": "人物", "all_videos_page_title": "视频", "app_bar_signout_dialog_content": "您确定要退出吗?", "app_bar_signout_dialog_ok": "是", "app_bar_signout_dialog_title": "退出登录", + "archived": "已存档", "archive_page_no_archived_assets": "未找到归档项目", "archive_page_title": "归档({})", "asset_action_delete_err_read_only": "无法删除只读项目,跳过", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "取消归档", "control_bottom_app_bar_unfavorite": "取消收藏", "control_bottom_app_bar_upload": "上传", + "create_album": "创建相册", "create_album_page_untitled": "未命名", + "create_new": "新建", "create_shared_album_page_create": "创建", "create_shared_album_page_share": "共享", "create_shared_album_page_share_add_assets": "添加项目", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "删除共享链接", "description_input_hint_text": "添加描述...", "description_input_submit_error": "更新描述时出错,请检查日志以获取更多详细信息", + "download_canceled": "下载已取消", + "download_complete": "下载完成", + "download_enqueue": "已加入下载队列", "download_error": "下载出错", + "download_failed": "下载失败", + "download_filename": "文件:{}", + "download_finished": "下载完成", + "downloading": "下载中...", + "downloading_media": "正在下载媒体", + "download_notfound": "无法找到下载", + "download_paused": "下载已暂停", "download_started": "开始下载", "download_sucess": "下载成功", "download_sucess_android": "媒体已下载至 DCIM/Immich", + "download_waiting_to_retry": "等待重试", "edit_date_time_dialog_date_time": "日期和时间", "edit_date_time_dialog_timezone": "时区", "edit_image_title": "编辑", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "启用实验性照片网格", "experimental_settings_subtitle": "使用风险自负!", "experimental_settings_title": "实验性功能", + "favorites": "收藏", "favorites_page_no_favorites": "未找到收藏项目", "favorites_page_title": "收藏", "filename_search": "文件名或扩展名", + "filter": "筛选", "haptic_feedback_switch": "启用振动反馈", "haptic_feedback_title": "振动反馈", "header_settings_add_header_tip": "添加标头", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "如果这是您第一次使用该应用程序,请确保选择一个要备份的本地相册,以便可以在时间线中预览该相册中的照片和视频。", "home_page_share_err_local": "暂无法通过链接共享本地项目,跳过", "home_page_upload_err_limit": "一次最多只能上传 30 个项目,跳过", + "ignore_icloud_photos": "忽略iCloud照片", + "ignore_icloud_photos_description": "存储在iCloud中的照片不会上传至Immich服务器", "image_saved_successfully": "图片已保存", "image_viewer_page_state_provider_download_error": "下载出现错误", "image_viewer_page_state_provider_download_started": "下载启动", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "共享出错", "invalid_date": "无效的日期", "invalid_date_format": "无效的日期格式", + "library": "库", "library_page_albums": "相册", "library_page_archive": "归档", "library_page_device_albums": "设备上的相册", @@ -342,6 +364,7 @@ "motion_photos_page_title": "动图", "multiselect_grid_edit_date_time_err_read_only": "无法编辑只读项目的日期,跳过", "multiselect_grid_edit_gps_err_read_only": "无法编辑只读项目的位置信息,跳过", + "my_albums": "我的相册", "no_assets_to_show": "无项目展示", "no_name": "无姓名", "notification_permission_dialog_cancel": "取消", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "授予通知权限。", "notification_permission_list_tile_enable_button": "启用通知", "notification_permission_list_tile_title": "通知权限", + "on_this_device": "在此设备", "partner_list_user_photos": "{user}的照片", "partner_list_view_all": "展示全部", "partner_page_add_partner": "添加同伴失败", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} 将无法再访问您的照片。", "partner_page_stop_sharing_title": "您确定要停止共享您的照片吗?", "partner_page_title": "同伴", + "partners": "伙伴", + "people": "人物", "permission_onboarding_back": "返回", "permission_onboarding_continue_anyway": "仍然继续", "permission_onboarding_get_started": "开始使用", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "已授权!一切就绪。", "permission_onboarding_permission_limited": "权限受限:要让 Immich 备份和管理您的整个图库收藏,请在“设置”中授予照片和视频权限。", "permission_onboarding_request": "Immich 需要权限才能查看您的照片和视频。", + "places": "地点", "preferences_settings_title": "偏好设置", "profile_drawer_app_logs": "日志", "profile_drawer_client_out_of_date_major": "客户端有大版本升级,请尽快升级至最新版。", @@ -383,9 +410,11 @@ "profile_drawer_settings": "设置", "profile_drawer_sign_out": "退出登录", "profile_drawer_trash": "回收站", + "recently_added": "近期添加", "recently_added_page_title": "最近添加", "save_to_gallery": "保存到图库", "scaffold_body_error_occurred": "发生错误", + "search_albums": "搜索相册", "search_bar_hint": "搜索照片", "search_filter_apply": "应用筛选", "search_filter_camera": "相机", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "更新", "shared_link_manage_links": "管理共享链接", "shared_link_public_album": "公共相册", + "shared_links": "共享链接", "share_done": "完成", + "shared_with_me": "共享给我", "share_invite": "邀请到共享相册", "sharing_page_album": "共享相册", "sharing_page_description": "创建共享相册以与网络中的人共享照片和视频。", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "三段式加载可能会提升加载性能,但可能会导致更高的网络负载", "theme_setting_three_stage_loading_title": "启用三段式加载", "translated_text_options": "选项", + "trash": "回收站", "trash_emptied": "空回收站", "trash_page_delete": "删除", "trash_page_delete_all": "删除全部", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "请花点时间访问", "version_announcement_overlay_text_3": "并检查您的 docker-compose 和 .env 是否为最新且正确的配置,特别是您在使用 WatchTower 或者其他自动更新的程序时,您需要更加细致的检查。", "version_announcement_overlay_title": "服务端有新版本啦 \uD83C\uDF89", + "videos": "视频", "viewer_remove_from_stack": "从堆叠中移除", "viewer_stack_use_as_main_asset": "作为主项目使用", "viewer_unstack": "取消堆叠" diff --git a/mobile/assets/i18n/zh-TW.json b/mobile/assets/i18n/zh-TW.json index 324c9069fdf460..0075f65de0557f 100644 --- a/mobile/assets/i18n/zh-TW.json +++ b/mobile/assets/i18n/zh-TW.json @@ -6,6 +6,7 @@ "action_common_save": "Save", "action_common_select": "Select", "action_common_update": "Update", + "add_a_name": "Add a name", "add_to_album_bottom_sheet_added": "Added to {album}", "add_to_album_bottom_sheet_already_exists": "Already in {album}", "advanced_settings_log_level_title": "Log level: {}", @@ -21,6 +22,7 @@ "advanced_settings_troubleshooting_title": "Troubleshooting", "album_info_card_backup_album_excluded": "EXCLUDED", "album_info_card_backup_album_included": "INCLUDED", + "albums": "Albums", "album_thumbnail_card_item": "1 item", "album_thumbnail_card_items": "{} items", "album_thumbnail_card_shared": " · Shared", @@ -36,11 +38,13 @@ "album_viewer_appbar_share_remove": "Remove from album", "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", + "all": "All", "all_people_page_title": "People", "all_videos_page_title": "Videos", "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_title": "Sign out", + "archived": "Archived", "archive_page_no_archived_assets": "No archived assets found", "archive_page_title": "Archive ({})", "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", @@ -185,7 +189,9 @@ "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_upload": "Upload", + "create_album": "Create album", "create_album_page_untitled": "Untitled", + "create_new": "CREATE NEW", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", "create_shared_album_page_share_add_assets": "ADD ASSETS", @@ -210,10 +216,21 @@ "delete_shared_link_dialog_title": "Delete Shared Link", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "download_canceled": "Download canceled", + "download_complete": "Download complete", + "download_enqueue": "Download enqueued", "download_error": "Download Error", + "download_failed": "Download failed", + "download_filename": "file: {}", + "download_finished": "Download finished", + "downloading": "Downloading...", + "downloading_media": "Downloading media", + "download_notfound": "Download not found", + "download_paused": "Download paused", "download_started": "Download started", "download_sucess": "Download success", "download_sucess_android": "The media has been downloaded to DCIM/Immich", + "download_waiting_to_retry": "Waiting to retry", "edit_date_time_dialog_date_time": "Date and Time", "edit_date_time_dialog_timezone": "Timezone", "edit_image_title": "Edit", @@ -229,9 +246,11 @@ "experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_title": "Experimental", + "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", "favorites_page_title": "Favorites", "filename_search": "File name or extension", + "filter": "Filter", "haptic_feedback_switch": "Enable haptic feedback", "haptic_feedback_title": "Haptic Feedback", "header_settings_add_header_tip": "Add Header", @@ -255,6 +274,8 @@ "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", "image_saved_successfully": "Image saved", "image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_started": "Download Started", @@ -262,6 +283,7 @@ "image_viewer_page_state_provider_share_error": "Share Error", "invalid_date": "Invalid date", "invalid_date_format": "Invalid date format", + "library": "Library", "library_page_albums": "Albums", "library_page_archive": "Archive", "library_page_device_albums": "Albums on Device", @@ -342,6 +364,7 @@ "motion_photos_page_title": "Motion Photos", "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", + "my_albums": "My albums", "no_assets_to_show": "No assets to show", "no_name": "No name", "notification_permission_dialog_cancel": "Cancel", @@ -350,6 +373,7 @@ "notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", + "on_this_device": "On this device", "partner_list_user_photos": "{user}'s photos", "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", @@ -361,6 +385,8 @@ "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_title": "Partner", + "partners": "Partners", + "people": "People", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", @@ -371,6 +397,7 @@ "permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "places": "Places", "preferences_settings_title": "Preferences", "profile_drawer_app_logs": "Logs", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", @@ -383,9 +410,11 @@ "profile_drawer_settings": "Settings", "profile_drawer_sign_out": "Sign Out", "profile_drawer_trash": "Trash", + "recently_added": "Recently added", "recently_added_page_title": "Recently Added", "save_to_gallery": "Save to gallery", "scaffold_body_error_occurred": "Error occurred", + "search_albums": "Search albums", "search_bar_hint": "Search your photos", "search_filter_apply": "Apply filter", "search_filter_camera": "Camera", @@ -531,7 +560,9 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", + "shared_links": "Shared links", "share_done": "Done", + "shared_with_me": "Shared with me", "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", @@ -563,6 +594,7 @@ "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_title": "Enable three-stage loading", "translated_text_options": "Options", + "trash": "Trash", "trash_emptied": "Emptied trash", "trash_page_delete": "Delete", "trash_page_delete_all": "Delete All", @@ -586,6 +618,7 @@ "version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", + "videos": "Videos", "viewer_remove_from_stack": "Remove from Stack", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_unstack": "Un-Stack" diff --git a/mobile/assets/polaroid-dark.png b/mobile/assets/polaroid-dark.png new file mode 100644 index 00000000000000..977897479b4c23 Binary files /dev/null and b/mobile/assets/polaroid-dark.png differ diff --git a/mobile/assets/polaroid-light.png b/mobile/assets/polaroid-light.png new file mode 100644 index 00000000000000..25cd7e54619d62 Binary files /dev/null and b/mobile/assets/polaroid-light.png differ diff --git a/mobile/ios/Gemfile.lock b/mobile/ios/Gemfile.lock index b41cba39e67c1a..218b8c13551c46 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 6a9d34ab83bfe3..567406aef0df23 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,7 +3,7 @@ PODS: - Flutter - connectivity_plus (0.0.1): - Flutter - - ReachabilitySwift + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.9): @@ -77,7 +77,6 @@ PODS: - photo_manager (2.0.0): - Flutter - FlutterMacOS - - ReachabilitySwift (5.0.0) - SAMKeychain (1.5.3) - SDWebImage (5.19.4): - SDWebImage/Core (= 5.19.4) @@ -102,7 +101,7 @@ PODS: DEPENDENCIES: - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) @@ -133,7 +132,6 @@ SPEC REPOS: - DKImagePickerController - DKPhotoGallery - MapLibre - - ReachabilitySwift - SAMKeychain - SDWebImage - SwiftyGif @@ -143,7 +141,7 @@ EXTERNAL SOURCES: background_downloader: :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" + :path: ".symlinks/plugins/connectivity_plus/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -195,8 +193,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf - connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 @@ -217,7 +215,6 @@ SPEC CHECKSUMS: path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a - ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 70bddbf10b9974..076edc078339fc 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 = 177; + 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 = 177; + 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 = 177; + 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 b6848040370107..4ed247245a6d9b 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.116.1 + 1.117.0 CFBundleSignature ???? CFBundleVersion - 177 + 179 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 8dc3676fb787a8..8ee5c9cc36e9fc 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.116.2" + version_number: "1.118.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/constants/filters.dart b/mobile/lib/constants/filters.dart new file mode 100644 index 00000000000000..d9fa2920b74598 --- /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/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index 38deac3f0ec618..6f6d1a6a31e88f 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -24,7 +24,10 @@ final Map _themePresetsMap = { ImmichColorPreset.indigo: ImmichTheme( light: ColorScheme.fromSeed( seedColor: immichBrandColorLight, - ).copyWith(primary: immichBrandColorLight), + ).copyWith( + primary: immichBrandColorLight, + onSurface: const Color.fromARGB(255, 34, 31, 32), + ), dark: ColorScheme.fromSeed( seedColor: immichBrandColorDark, brightness: Brightness.dark, diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index df902ca995e9bd..8e2d9c84d5d1ee 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -22,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 = @@ -507,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 8be636efb659bc..23bf23604635dd 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -57,64 +57,69 @@ const AssetSchema = CollectionSchema( name: r'isFavorite', type: IsarType.bool, ), - r'isTrashed': PropertySchema( + r'isOffline': PropertySchema( id: 8, + name: r'isOffline', + type: IsarType.bool, + ), + r'isTrashed': PropertySchema( + id: 9, name: r'isTrashed', type: IsarType.bool, ), r'livePhotoVideoId': PropertySchema( - id: 9, + id: 10, name: r'livePhotoVideoId', type: IsarType.string, ), r'localId': PropertySchema( - id: 10, + id: 11, name: r'localId', type: IsarType.string, ), r'ownerId': PropertySchema( - id: 11, + id: 12, name: r'ownerId', type: IsarType.long, ), r'remoteId': PropertySchema( - id: 12, + id: 13, name: r'remoteId', type: IsarType.string, ), r'stackCount': PropertySchema( - id: 13, + id: 14, name: r'stackCount', type: IsarType.long, ), r'stackId': PropertySchema( - id: 14, + id: 15, name: r'stackId', type: IsarType.string, ), r'stackPrimaryAssetId': PropertySchema( - id: 15, + id: 16, name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 16, + id: 17, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 17, + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -239,18 +244,19 @@ 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.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); + 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); } Asset _assetDeserialize( @@ -269,19 +275,20 @@ Asset _assetDeserialize( id: id, isArchived: reader.readBoolOrNull(offsets[6]) ?? false, isFavorite: reader.readBoolOrNull(offsets[7]) ?? false, - 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])] ?? + 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])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -312,27 +319,29 @@ P _assetDeserializeProp

( case 8: return (reader.readBoolOrNull(offset) ?? false) as P; case 9: - return (reader.readStringOrNull(offset)) as P; + return (reader.readBoolOrNull(offset) ?? false) as P; case 10: return (reader.readStringOrNull(offset)) as P; case 11: - return (reader.readLong(offset)) as P; - case 12: return (reader.readStringOrNull(offset)) as P; + case 12: + return (reader.readLong(offset)) as P; case 13: - return (reader.readLongOrNull(offset) ?? 0) as P; - case 14: return (reader.readStringOrNull(offset)) as P; + case 14: + return (reader.readLongOrNull(offset) ?? 0) 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 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1353,6 +1362,16 @@ 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) { @@ -2628,6 +2647,18 @@ 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); @@ -2882,6 +2913,18 @@ 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); @@ -3078,6 +3121,12 @@ 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'); @@ -3214,6 +3263,12 @@ 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/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index ba188f127009aa..bdf11f18de8acd 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -2,6 +2,7 @@ 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'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); @@ -38,6 +39,8 @@ abstract interface class IAlbumRepository implements IDatabaseRepository { Future removeAssets(Album album, List assets); Future recalculateMetadata(Album album); + + Future> search(String searchTerm, QuickFilterMode filterMode); } enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/models/albums/album_search.model.dart b/mobile/lib/models/albums/album_search.model.dart new file mode 100644 index 00000000000000..ac4eedbff1bd86 --- /dev/null +++ b/mobile/lib/models/albums/album_search.model.dart @@ -0,0 +1,5 @@ +enum QuickFilterMode { + all, + sharedWithMe, + myAlbums, +} diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 297a819b6a335e..47baf356b7f6ae 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -266,8 +266,8 @@ class SearchFilter { AssetType? mediaType, }) { return SearchFilter( - context: context ?? this.context, - filename: filename ?? this.filename, + context: context, + filename: filename, people: people ?? this.people, location: location ?? this.location, camera: camera ?? this.camera, diff --git a/mobile/lib/pages/albums/albums.page.dart b/mobile/lib/pages/albums/albums.page.dart new file mode 100644 index 00000000000000..e466149ac3ee8a --- /dev/null +++ b/mobile/lib/pages/albums/albums.page.dart @@ -0,0 +1,469 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; +import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; + +@RoutePage() +class AlbumsPage extends HookConsumerWidget { + const AlbumsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = + ref.watch(albumProvider).where((album) => album.isRemote).toList(); + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + final sorted = albumSortOption.sortFn(albums, albumSortIsReverse); + final isGrid = useState(false); + final searchController = useTextEditingController(); + final debounceTimer = useRef(null); + final filterMode = useState(QuickFilterMode.all); + final userId = ref.watch(currentUserProvider)?.id; + final searchFocusNode = useFocusNode(); + + toggleViewMode() { + isGrid.value = !isGrid.value; + } + + onSearch(String searchTerm, QuickFilterMode mode) { + debounceTimer.value?.cancel(); + debounceTimer.value = Timer(const Duration(milliseconds: 300), () { + ref.read(albumProvider.notifier).searchAlbums(searchTerm, mode); + }); + } + + changeFilter(QuickFilterMode mode) { + filterMode.value = mode; + } + + useEffect( + () { + searchController.addListener(() { + onSearch(searchController.text, filterMode.value); + }); + + return () { + searchController.removeListener(() { + onSearch(searchController.text, filterMode.value); + }); + debounceTimer.value?.cancel(); + }; + }, + [], + ); + + clearSearch() { + filterMode.value = QuickFilterMode.all; + searchController.clear(); + onSearch('', QuickFilterMode.all); + } + + return Scaffold( + appBar: ImmichAppBar( + showUploadButton: false, + actions: [ + IconButton( + icon: Icon( + Icons.add_rounded, + size: 28, + ), + onPressed: () => context.pushRoute( + CreateAlbumRoute(), + ), + ), + ], + ), + body: RefreshIndicator( + displacement: 70, + onRefresh: () async { + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); + }, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), + children: [ + Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withOpacity(0.075), + context.colorScheme.primary.withOpacity(0.09), + context.colorScheme.primary.withOpacity(0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + transform: GradientRotation(0.5 * pi), + ), + ), + child: TextField( + autofocus: false, + decoration: InputDecoration( + contentPadding: EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainer, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(100), + ), + ), + hintText: 'search_albums'.tr(), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded), + onPressed: clearSearch, + ) + : const SizedBox.shrink(), + ), + controller: searchController, + onChanged: (_) => + onSearch(searchController.text, filterMode.value), + focusNode: searchFocusNode, + onTapOutside: (_) => searchFocusNode.unfocus(), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + QuickFilterButton( + label: 'all'.tr(), + isSelected: filterMode.value == QuickFilterMode.all, + onTap: () { + changeFilter(QuickFilterMode.all); + onSearch(searchController.text, QuickFilterMode.all); + }, + ), + QuickFilterButton( + label: 'shared_with_me'.tr(), + isSelected: filterMode.value == QuickFilterMode.sharedWithMe, + onTap: () { + changeFilter(QuickFilterMode.sharedWithMe); + onSearch( + searchController.text, + QuickFilterMode.sharedWithMe, + ); + }, + ), + QuickFilterButton( + label: 'my_albums'.tr(), + isSelected: filterMode.value == QuickFilterMode.myAlbums, + onTap: () { + changeFilter(QuickFilterMode.myAlbums); + onSearch( + searchController.text, + QuickFilterMode.myAlbums, + ); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SortButton(), + IconButton( + icon: Icon( + isGrid.value + ? Icons.view_list_outlined + : Icons.grid_view_outlined, + size: 24, + ), + onPressed: toggleViewMode, + ), + ], + ), + const SizedBox(height: 5), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: isGrid.value + ? GridView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 250, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: .7, + ), + itemBuilder: (context, index) { + return AlbumThumbnailCard( + album: sorted[index], + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + showOwner: true, + ); + }, + itemCount: sorted.length, + ) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sorted.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + title: Text( + sorted[index].name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: sorted[index].ownerId == userId + ? Text( + '${sorted[index].assetCount} items', + overflow: TextOverflow.ellipsis, + style: + context.textTheme.bodyMedium?.copyWith( + color: context + .colorScheme.onSurfaceSecondary, + ), + ) + : sorted[index].ownerName != null + ? Text( + '${sorted[index].assetCount} items • ${'album_thumbnail_shared_by'.tr( + args: [ + sorted[index].ownerName!, + ], + )}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium + ?.copyWith( + color: context + .colorScheme.onSurfaceSecondary, + ), + ) + : null, + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(15), + ), + child: ImmichThumbnail( + asset: sorted[index].thumbnail.value, + width: 80, + height: 80, + ), + ), + // minVerticalPadding: 1, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class QuickFilterButton extends StatelessWidget { + const QuickFilterButton({ + super.key, + required this.isSelected, + required this.onTap, + required this.label, + }); + + final bool isSelected; + final VoidCallback onTap; + final String label; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onTap, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + isSelected ? context.colorScheme.primary : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(25), + width: 1, + ), + ), + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + fontSize: 14, + ), + ), + ); + } +} + +class SortButton extends ConsumerWidget { + const SortButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + + return MenuAnchor( + style: MenuStyle( + elevation: WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + padding: WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + consumeOutsideTap: true, + menuChildren: AlbumSortMode.values + .map( + (mode) => MenuItemButton( + leadingIcon: albumSortOption == mode + ? albumSortIsReverse + ? Icon( + Icons.keyboard_arrow_down, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : Icon( + Icons.keyboard_arrow_up_rounded, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : const Icon(Icons.abc, color: Colors.transparent), + onPressed: () { + final selected = albumSortOption == mode; + // Switch direction + if (selected) { + ref + .read(albumSortOrderProvider.notifier) + .changeSortDirection(!albumSortIsReverse); + } else { + ref + .read(albumSortByOptionsProvider.notifier) + .changeSortMode(mode); + } + }, + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.fromLTRB(16, 16, 32, 16), + ), + backgroundColor: WidgetStateProperty.all( + albumSortOption == mode + ? context.colorScheme.primary + : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + child: Text( + mode.label.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface.withAlpha(185), + ), + ), + ), + ) + .toList(), + builder: (context, controller, child) { + return GestureDetector( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 5), + child: Transform.rotate( + angle: 90 * pi / 180, + child: Icon( + Icons.compare_arrows_rounded, + size: 18, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ), + Text( + albumSortOption.label.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index 8dccece325d8f2..0869e75e9fc149 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -151,7 +151,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { handleSyncAlbumToggle(bool isEnable) async { if (isEnable) { - await ref.read(albumProvider.notifier).getAllAlbums(); + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); for (final album in selectedBackupAlbums) { await ref.read(albumProvider.notifier).createSyncAlbum(album.name); } diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index bb9d462e50bc4a..d8baecf808d63b 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -212,7 +212,7 @@ class BackupControllerPage extends HookConsumerWidget { .read(backupProvider.notifier) .backupAlbumSelectionDone(); // waited until backup albums are stored in DB - ref.read(albumProvider.notifier).getDeviceAlbums(); + ref.read(albumProvider.notifier).refreshDeviceAlbums(); }, child: const Text( "backup_controller_page_select", diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index 3cc30af7a97f1b..93e4c180fed6b6 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -6,7 +6,7 @@ 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/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -45,11 +45,11 @@ class AlbumOptionsPage extends HookConsumerWidget { try { final isSuccess = - await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album); + await ref.read(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { context.navigateTo( - const TabControllerRoute(children: [SharingRoute()]), + TabControllerRoute(children: [AlbumsRoute()]), ); } else { showErrorMessage(); @@ -65,9 +65,7 @@ class AlbumOptionsPage extends HookConsumerWidget { isProcessing.value = true; try { - await ref - .read(sharedAlbumProvider.notifier) - .removeUserFromAlbum(album, user); + await ref.read(albumProvider.notifier).removeUser(album, user); album.sharedUsers.remove(user); sharedUsers.value = album.sharedUsers.toList(); } catch (error) { @@ -200,8 +198,8 @@ class AlbumOptionsPage extends HookConsumerWidget { onChanged: (bool value) async { activityEnabled.value = value; if (await ref - .read(sharedAlbumProvider.notifier) - .setActivityEnabled(album, value)) { + .read(albumProvider.notifier) + .setActivitystatus(album, value)) { album.activityEnabled = value; } }, diff --git a/mobile/lib/pages/common/album_shared_user_selection.page.dart b/mobile/lib/pages/common/album_shared_user_selection.page.dart index aefa8e273612ce..9dadef1a76f8a1 100644 --- a/mobile/lib/pages/common/album_shared_user_selection.page.dart +++ b/mobile/lib/pages/common/album_shared_user_selection.page.dart @@ -5,8 +5,8 @@ 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/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_title.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -25,20 +25,15 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget { final suggestedShareUsers = ref.watch(otherUsersProvider); createSharedAlbum() async { - var newAlbum = - await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum( - ref.watch(albumTitleProvider), - assets, - sharedUsersList.value, - ); + var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( + ref.watch(albumTitleProvider), + assets, + ); if (newAlbum != null) { - await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); - // ref.watch(assetSelectionProvider.notifier).removeAll(); ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); context.maybePop(true); - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } ScaffoldMessenger( diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index 33b314f3b105b5..b977128cfa25c0 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -11,9 +11,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart'; import 'package:immich_mobile/providers/album/album.provider.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/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; @@ -50,9 +48,7 @@ class AlbumViewerPage extends HookConsumerWidget { Future onRemoveFromAlbumPressed(Iterable assets) async { final a = album.valueOrNull; final bool isSuccess = a != null && - await ref - .read(sharedAlbumProvider.notifier) - .removeAssetFromAlbum(a, assets); + await ref.read(albumProvider.notifier).removeAsset(a, assets); if (!isSuccess) { ImmichToast.show( @@ -81,9 +77,9 @@ class AlbumViewerPage extends HookConsumerWidget { // Check if there is new assets add isProcessing.value = true; - await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( - returnPayload.selectedAssets, + await ref.watch(albumProvider.notifier).addAssets( albumInfo, + returnPayload.selectedAssets, ); isProcessing.value = false; @@ -98,9 +94,7 @@ class AlbumViewerPage extends HookConsumerWidget { if (sharedUserIds != null) { isProcessing.value = true; - await ref - .watch(albumServiceProvider) - .addAdditionalUserToAlbum(sharedUserIds, album); + await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds); isProcessing.value = false; } @@ -184,27 +178,29 @@ class AlbumViewerPage extends HookConsumerWidget { } Widget buildSharedUserIconsRow(Album album) { - return GestureDetector( - onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)), - child: SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemBuilder: ((context, index) { - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: UserCircleAvatar( - user: album.sharedUsers.toList()[index], - radius: 18, - size: 36, + return album.sharedUsers.isNotEmpty + ? GestureDetector( + onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)), + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.only(left: 16), + scrollDirection: Axis.horizontal, + itemBuilder: ((context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: UserCircleAvatar( + user: album.sharedUsers.toList()[index], + radius: 18, + size: 36, + ), + ); + }), + itemCount: album.sharedUsers.length, ), - ); - }), - itemCount: album.sharedUsers.length, - ), - ), - ); + ), + ) + : const SizedBox.shrink(); } Widget buildHeader(Album album) { @@ -214,7 +210,7 @@ class AlbumViewerPage extends HookConsumerWidget { children: [ buildTitle(album), if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), - if (album.shared) buildSharedUserIconsRow(album), + buildSharedUserIconsRow(album), ], ); } @@ -231,17 +227,17 @@ class AlbumViewerPage extends HookConsumerWidget { body: Stack( children: [ album.widgetWhen( - onData: (data) => MultiselectGrid( + onData: (albumInfo) => MultiselectGrid( renderListProvider: albumRenderlistProvider(albumId), topWidget: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - buildHeader(data), - if (data.isRemote) buildControlButton(data), + buildHeader(albumInfo), + if (albumInfo.isRemote) buildControlButton(albumInfo), ], ), onRemoveFromAlbum: onRemoveFromAlbumPressed, - editEnabled: data.ownerId == userId, + editEnabled: albumInfo.ownerId == userId, ), ), AnimatedPositioned( diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 1fd860520d5c7a..55261f6d55304e 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -17,13 +17,11 @@ import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart'; @RoutePage() // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { - final bool isSharedAlbum; - final List? initialAssets; + final List? assets; const CreateAlbumPage({ super.key, - required this.isSharedAlbum, - this.initialAssets, + this.assets, }); @override @@ -34,18 +32,9 @@ class CreateAlbumPage extends HookConsumerWidget { final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); final selectedAssets = useState>( - initialAssets != null ? Set.from(initialAssets!) : const {}, + assets != null ? Set.from(assets!) : const {}, ); - showSelectUserPage() async { - final bool? ok = await context.pushRoute( - AlbumSharedUserSelectionRoute(assets: selectedAssets.value), - ); - if (ok == true) { - selectedAssets.value = {}; - } - } - void onBackgroundTapped() { albumTitleTextFieldFocusNode.unfocus(); isAlbumTitleTextFieldFocus.value = false; @@ -199,7 +188,7 @@ class CreateAlbumPage extends HookConsumerWidget { ); if (newAlbum != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); + ref.watch(albumProvider.notifier).refreshRemoteAlbums(); selectedAssets.value = {}; ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); @@ -223,36 +212,20 @@ class CreateAlbumPage extends HookConsumerWidget { 'share_create_album', ).tr(), actions: [ - if (isSharedAlbum) - TextButton( - onPressed: albumTitleController.text.isNotEmpty - ? showSelectUserPage - : null, - child: Text( - 'create_shared_album_page_share'.tr(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: albumTitleController.text.isEmpty - ? context.themeData.disabledColor - : context.primaryColor, - ), - ), - ), - if (!isSharedAlbum) - TextButton( - onPressed: albumTitleController.text.isNotEmpty - ? createNonSharedAlbum - : null, - child: Text( - 'create_shared_album_page_create'.tr(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: albumTitleController.text.isNotEmpty - ? context.primaryColor - : context.themeData.disabledColor, - ), + TextButton( + onPressed: albumTitleController.text.isNotEmpty + ? createNonSharedAlbum + : null, + child: Text( + 'create_shared_album_page_create'.tr(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: albumTitleController.text.isNotEmpty + ? context.primaryColor + : context.themeData.disabledColor, ), ), + ), ], ), body: GestureDetector( diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart new file mode 100644 index 00000000000000..8213ca423f2689 --- /dev/null +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class LargeLeadingTile extends StatelessWidget { + const LargeLeadingTile({ + super.key, + required this.leading, + required this.onTap, + required this.title, + this.subtitle, + this.leadingPadding = const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16.0, + ), + this.borderRadius = 20.0, + }); + + final Widget leading; + final VoidCallback onTap; + final Widget title; + final Widget? subtitle; + final EdgeInsetsGeometry leadingPadding; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: leadingPadding, + child: leading, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * 0.6, + child: title, + ), + subtitle ?? const SizedBox.shrink(), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index b619e003d2c3a8..3d4b19cba0c56d 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -3,8 +3,10 @@ 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/album/album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; @@ -16,10 +18,11 @@ class TabControllerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final refreshing = ref.watch(assetProvider); + final isRefreshingAssets = ref.watch(assetProvider); + final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider); - Widget buildIcon(Widget icon) { - if (!refreshing) return icon; + Widget buildIcon({required Widget icon, required bool isProcessing}) { + if (!isProcessing) return icon; return Stack( alignment: Alignment.center, clipBehavior: Clip.none, @@ -42,21 +45,28 @@ class TabControllerPage extends HookConsumerWidget { ); } + onNavigationSelected(TabsRouter router, int index) { + // On Photos page menu tapped + if (router.activeIndex == 0 && index == 0) { + scrollToTopNotifierProvider.scrollToTop(); + } + + // On Search page tapped + if (router.activeIndex == 1 && index == 1) { + ref.read(searchInputFocusProvider).requestFocus(); + } + + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + router.setActiveIndex(index); + ref.read(tabProvider.notifier).state = TabEnum.values[index]; + } + navigationRail(TabsRouter tabsRouter) { return NavigationRail( labelType: NavigationRailLabelType.all, selectedIndex: tabsRouter.activeIndex, - onDestinationSelected: (index) { - // Selected Photos while it is active - if (tabsRouter.activeIndex == 0 && index == 0) { - // Scroll to top - scrollToTopNotifierProvider.scrollToTop(); - } - - ref.read(hapticFeedbackProvider.notifier).selectionClick(); - tabsRouter.setActiveIndex(index); - ref.read(tabProvider.notifier).state = TabEnum.values[index]; - }, + onDestinationSelected: (index) => + onNavigationSelected(tabsRouter, index), selectedIconTheme: IconThemeData( color: context.primaryColor, ), @@ -84,15 +94,15 @@ class TabControllerPage extends HookConsumerWidget { ), NavigationRailDestination( padding: const EdgeInsets.all(4), - icon: const Icon(Icons.share_rounded), - selectedIcon: const Icon(Icons.share), - label: const Text('tab_controller_nav_sharing').tr(), + icon: const Icon(Icons.photo_album_outlined), + selectedIcon: const Icon(Icons.photo_album), + label: const Text('albums').tr(), ), NavigationRailDestination( padding: const EdgeInsets.all(4), - icon: const Icon(Icons.photo_album_outlined), - selectedIcon: const Icon(Icons.photo_album), - label: const Text('tab_controller_nav_library').tr(), + icon: const Icon(Icons.space_dashboard_outlined), + selectedIcon: const Icon(Icons.space_dashboard_rounded), + label: const Text('library').tr(), ), ], ); @@ -101,16 +111,8 @@ class TabControllerPage extends HookConsumerWidget { bottomNavigationBar(TabsRouter tabsRouter) { return NavigationBar( selectedIndex: tabsRouter.activeIndex, - onDestinationSelected: (index) { - if (tabsRouter.activeIndex == 0 && index == 0) { - // Scroll to top - scrollToTopNotifierProvider.scrollToTop(); - } - - ref.read(hapticFeedbackProvider.notifier).selectionClick(); - tabsRouter.setActiveIndex(index); - ref.read(tabProvider.notifier).state = TabEnum.values[index]; - }, + onDestinationSelected: (index) => + onNavigationSelected(tabsRouter, index), destinations: [ NavigationDestination( label: 'tab_controller_nav_photos'.tr(), @@ -118,7 +120,8 @@ class TabControllerPage extends HookConsumerWidget { Icons.photo_library_outlined, ), selectedIcon: buildIcon( - Icon( + isProcessing: isRefreshingAssets, + icon: Icon( Icons.photo_library, color: context.primaryColor, ), @@ -135,23 +138,27 @@ class TabControllerPage extends HookConsumerWidget { ), ), NavigationDestination( - label: 'tab_controller_nav_sharing'.tr(), + label: 'albums'.tr(), icon: const Icon( - Icons.group_outlined, + Icons.photo_album_outlined, ), - selectedIcon: Icon( - Icons.group, - color: context.primaryColor, + selectedIcon: buildIcon( + isProcessing: isRefreshingRemoteAlbums, + icon: Icon( + Icons.photo_album_rounded, + color: context.primaryColor, + ), ), ), NavigationDestination( - label: 'tab_controller_nav_library'.tr(), + label: 'library'.tr(), icon: const Icon( - Icons.photo_album_outlined, + Icons.space_dashboard_outlined, ), selectedIcon: buildIcon( - Icon( - Icons.photo_album_rounded, + isProcessing: isRefreshingAssets, + icon: Icon( + Icons.space_dashboard_rounded, color: context.primaryColor, ), ), @@ -162,11 +169,11 @@ class TabControllerPage extends HookConsumerWidget { final multiselectEnabled = ref.watch(multiselectProvider); return AutoTabsRouter( - routes: const [ - PhotosRoute(), + routes: [ + const PhotosRoute(), SearchRoute(), - SharingRoute(), - LibraryRoute(), + const AlbumsRoute(), + const LibraryRoute(), ], duration: const Duration(milliseconds: 600), transitionBuilder: (context, child, animation) => FadeTransition( diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index 5c0c185dbce0ae..650d2dc912db95 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'; @@ -9,7 +8,6 @@ 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/repositories/file_media.repository.dart'; -import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -71,7 +69,7 @@ class EditImagePage extends ConsumerWidget { imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg", ); - await ref.read(albumProvider.notifier).getDeviceAlbums(); + await ref.read(albumProvider.notifier).refreshDeviceAlbums(); Navigator.of(context).popUntil((route) => route.isFirst); ImmichToast.show( durationInSecond: 3, @@ -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 00000000000000..da8ba748915953 --- /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/library.page.dart b/mobile/lib/pages/library/library.page.dart index 5f03ed68714c88..3915ac39914605 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -1,324 +1,355 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.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/user.entity.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/providers/partner.provider.dart'; +import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; +import 'package:immich_mobile/widgets/common/user_avatar.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class LibraryPage extends HookConsumerWidget { +class LibraryPage extends ConsumerWidget { const LibraryPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final albums = ref.watch(albumProvider); - final albumSortOption = ref.watch(albumSortByOptionsProvider); - final albumSortIsReverse = ref.watch(albumSortOrderProvider); - - useEffect( - () { - ref.read(albumProvider.notifier).getAllAlbums(); - return null; - }, - [], - ); - Widget buildSortButton() { - return PopupMenuButton( - position: PopupMenuPosition.over, - itemBuilder: (BuildContext context) { - return AlbumSortMode.values - .map>((option) { - final selected = albumSortOption == option; - return PopupMenuItem( - value: option, + return Scaffold( + appBar: ImmichAppBar(), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), child: Row( children: [ - Padding( - padding: const EdgeInsets.only(right: 12.0), - child: Icon( - Icons.check, - color: - selected ? context.primaryColor : Colors.transparent, - ), + ActionButton( + onPressed: () => context.pushRoute(const FavoritesRoute()), + icon: Icons.favorite_outline_rounded, + label: 'favorites'.tr(), ), - Text( - option.label.tr(), - style: TextStyle( - color: selected ? context.primaryColor : null, - fontSize: 14.0, - ), + const SizedBox(width: 8), + ActionButton( + onPressed: () => context.pushRoute(const ArchiveRoute()), + icon: Icons.archive_outlined, + label: 'archived'.tr(), ), ], ), - ); - }).toList(); - }, - onSelected: (AlbumSortMode value) { - final selected = albumSortOption == value; - // Switch direction - if (selected) { - ref - .read(albumSortOrderProvider.notifier) - .changeSortDirection(!albumSortIsReverse); - } else { - ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value); - } - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 5), - child: Icon( - albumSortIsReverse - ? Icons.arrow_downward_rounded - : Icons.arrow_upward_rounded, - size: 14, - color: context.primaryColor, - ), ), - Text( - albumSortOption.label.tr(), - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), + const SizedBox(height: 8), + Row( + children: [ + ActionButton( + onPressed: () => context.pushRoute(const SharedLinkRoute()), + icon: Icons.link_outlined, + label: 'shared_links'.tr(), + ), + const SizedBox(width: 8), + trashEnabled + ? ActionButton( + onPressed: () => context.pushRoute(const TrashRoute()), + icon: Icons.delete_outline_rounded, + label: 'trash'.tr(), + ) + : const SizedBox.shrink(), + ], + ), + const SizedBox(height: 12), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + PeopleCollectionCard(), + PlacesCollectionCard(), + LocalAlbumsCollectionCard(), + ], + ), + const SizedBox(height: 12), + QuickAccessButtons(), + const SizedBox( + height: 32, ), ], ), - ); - } + ), + ); + } +} - Widget buildCreateAlbumButton() { - return LayoutBuilder( - builder: (context, constraints) { - var cardSize = constraints.maxWidth; +class QuickAccessButtons extends ConsumerWidget { + const QuickAccessButtons({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final partners = ref.watch(partnerSharedWithProvider); - return GestureDetector( - onTap: () => - context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)), - child: Padding( - padding: - const EdgeInsets.only(bottom: 32), // Adjust padding to suit - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: cardSize, - height: cardSize, - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainer, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: Center( - child: Icon( - Icons.add_rounded, - size: 28, - color: context.primaryColor, - ), - ), - ), - Padding( - padding: const EdgeInsets.only( - top: 8.0, - bottom: 16, - ), - child: Text( - 'library_page_new_album', - style: context.textTheme.labelLarge?.copyWith( - color: context.colorScheme.onSurface, - ), - ).tr(), - ), - ], + return Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + context.colorScheme.primary.withAlpha(20), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0), + bottomRight: Radius.circular(partners.isEmpty ? 20 : 0), ), ), - ); - }, - ); - } - - Widget buildLibraryNavButton( - String label, - IconData icon, - Function() onClick, - ) { - return Expanded( - child: FilledButton.icon( - onPressed: onClick, - label: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - label, - style: TextStyle( - color: context.colorScheme.onSurface, + leading: const Icon( + Icons.group_outlined, + size: 26, + ), + title: Text( + 'partners'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, ), ), + onTap: () => context.pushRoute(const PartnerRoute()), ), - style: FilledButton.styleFrom( - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - backgroundColor: context.colorScheme.surfaceContainer, - alignment: Alignment.centerLeft, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), + PartnerList(partners: partners), + ], + ), + ); + } +} + +class PartnerList extends ConsumerWidget { + const PartnerList({super.key, required this.partners}); + + final List partners; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: partners.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final partner = partners[index]; + final isLastItem = index == partners.length - 1; + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(isLastItem ? 20 : 0), + bottomRight: Radius.circular(isLastItem ? 20 : 0), ), ), - icon: Icon( - icon, - color: context.primaryColor, + contentPadding: const EdgeInsets.only( + left: 12.0, + right: 18.0, ), - ), - ); - } - - final remote = albums.where((a) => a.isRemote).toList(); - final sorted = albumSortOption.sortFn(remote, albumSortIsReverse); - final local = albums.where((a) => a.isLocal).toList(); + leading: userAvatar(context, partner, radius: 16), + title: Text( + "partner_list_user_photos", + style: TextStyle( + fontWeight: FontWeight.w500, + ), + ).tr( + namedArgs: { + 'user': partner.name, + }, + ), + onTap: () => context.pushRoute( + (PartnerDetailRoute(partner: partner)), + ), + ); + }, + ); + } +} - Widget? shareTrashButton() { - return trashEnabled - ? InkWell( - onTap: () => context.pushRoute(const TrashRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Icon( - Icons.delete_rounded, - size: 25, - semanticLabel: 'profile_drawer_trash'.tr(), - ), - ) - : null; - } +class PeopleCollectionCard extends ConsumerWidget { + const PeopleCollectionCard({super.key}); - return Scaffold( - appBar: ImmichAppBar( - action: shareTrashButton(), - ), - body: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 12.0, - top: 24.0, - bottom: 12.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - buildLibraryNavButton( - "library_page_favorites".tr(), Icons.favorite_border, () { - context.navigateTo(const FavoritesRoute()); - }), - const SizedBox(width: 12.0), - buildLibraryNavButton( - "library_page_archive".tr(), Icons.archive_outlined, () { - context.navigateTo(const ArchiveRoute()); - }), + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = ref.watch(getAllPeopleProvider); + final size = MediaQuery.of(context).size.width * 0.5 - 20; + return GestureDetector( + onTap: () => context.pushRoute(const PeopleCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(30), + context.colorScheme.primary.withAlpha(25), ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), ), + child: people.widgetWhen( + onData: (people) { + return GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: people.take(4).map((person) { + return CircleAvatar( + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: ApiService.getRequestHeaders(), + ), + ); + }).toList(), + ); + }, + ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - top: 12.0, - left: 12.0, - right: 12.0, - bottom: 20.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'library_page_albums', - style: context.textTheme.bodyLarge?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ).tr(), - buildSortButton(), - ], + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'people'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, ), ), ), - SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - childCount: sorted.length + 1, - (context, index) { - if (index == 0) { - return buildCreateAlbumButton(); - } + ], + ), + ); + } +} - return AlbumThumbnailCard( - album: sorted[index - 1], - onTap: () => context.pushRoute( - AlbumViewerRoute( - albumId: sorted[index - 1].id, - ), - ), - ); - }, +class LocalAlbumsCollectionCard extends HookConsumerWidget { + const LocalAlbumsCollectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(localAlbumsProvider); + + final size = MediaQuery.of(context).size.width * 0.5 - 20; + + return GestureDetector( + onTap: () => context.pushRoute( + const LocalAlbumsRoute(), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(30), + context.colorScheme.primary.withAlpha(25), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), ), + child: GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: albums.take(4).map((album) { + return AlbumThumbnailCard( + album: album, + showTitle: false, + ); + }).toList(), + ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - top: 12.0, - left: 12.0, - right: 12.0, - bottom: 20.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'library_page_device_albums', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ], + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'on_this_device'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, ), ), ), - SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - childCount: local.length, - (context, index) => AlbumThumbnailCard( - album: local[index], - onTap: () => context.pushRoute( - AlbumViewerRoute( - albumId: local[index].id, - ), - ), + ], + ), + ); + } +} + +class PlacesCollectionCard extends StatelessWidget { + const PlacesCollectionCard({super.key}); + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size.width * 0.5 - 20; + return GestureDetector( + onTap: () => context.pushRoute(const PlacesCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: context.colorScheme.secondaryContainer.withAlpha(100), + ), + child: IgnorePointer( + child: MapThumbnail( + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'places'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, ), ), ), @@ -327,3 +358,52 @@ class LibraryPage extends HookConsumerWidget { ); } } + +class ActionButton extends StatelessWidget { + final VoidCallback onPressed; + final IconData icon; + final String label; + + const ActionButton({ + super.key, + required this.onPressed, + required this.icon, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: FilledButton.icon( + onPressed: onPressed, + label: Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + label, + style: TextStyle( + color: context.colorScheme.onSurface, + fontSize: 15, + ), + ), + ), + style: FilledButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + backgroundColor: context.colorScheme.surfaceContainerLow, + alignment: Alignment.centerLeft, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(25)), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + ), + ), + icon: Icon( + icon, + color: context.primaryColor, + ), + ), + ); + } +} diff --git a/mobile/lib/pages/library/local_albums.page.dart b/mobile/lib/pages/library/local_albums.page.dart new file mode 100644 index 00000000000000..164ea3bad883f9 --- /dev/null +++ b/mobile/lib/pages/library/local_albums.page.dart @@ -0,0 +1,55 @@ +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/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; + +@RoutePage() +class LocalAlbumsPage extends HookConsumerWidget { + const LocalAlbumsPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(localAlbumsProvider); + + return Scaffold( + appBar: AppBar( + title: Text('on_this_device'.tr()), + ), + body: ListView.builder( + padding: const EdgeInsets.all(18.0), + itemCount: albums.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: ImmichThumbnail( + asset: albums[index].thumbnail.value, + width: 80, + height: 80, + ), + ), + title: Text( + albums[index].name, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text('${albums[index].assetCount} items'), + onTap: () => context + .pushRoute(AlbumViewerRoute(albumId: albums[index].id)), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/pages/sharing/partner/partner.page.dart b/mobile/lib/pages/library/partner/partner.page.dart similarity index 93% rename from mobile/lib/pages/sharing/partner/partner.page.dart rename to mobile/lib/pages/library/partner/partner.page.dart index 8dd31023c7cad4..1e9e801210e5ea 100644 --- a/mobile/lib/pages/sharing/partner/partner.page.dart +++ b/mobile/lib/pages/library/partner/partner.page.dart @@ -86,12 +86,10 @@ class PartnerPage extends HookConsumerWidget { children: [ Padding( padding: const EdgeInsets.only(left: 16.0, top: 16.0), - child: const Text( + child: Text( "partner_page_shared_to_title", - style: TextStyle( - fontSize: 14, - color: Colors.grey, - fontWeight: FontWeight.bold, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface.withAlpha(200), ), ).tr(), ), @@ -104,10 +102,7 @@ class PartnerPage extends HookConsumerWidget { leading: userAvatar(context, users[index]), title: Text( users[index].email, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), + style: context.textTheme.bodyLarge, ), trailing: IconButton( icon: const Icon(Icons.person_remove), @@ -148,7 +143,7 @@ class PartnerPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text("partner_page_title").tr(), + title: const Text("partners").tr(), elevation: 0, centerTitle: false, actions: [ diff --git a/mobile/lib/pages/sharing/partner/partner_detail.page.dart b/mobile/lib/pages/library/partner/partner_detail.page.dart similarity index 59% rename from mobile/lib/pages/sharing/partner/partner_detail.page.dart rename to mobile/lib/pages/library/partner/partner_detail.page.dart index 8a2dd4b8203797..0874aacfa7f53f 100644 --- a/mobile/lib/pages/sharing/partner/partner_detail.page.dart +++ b/mobile/lib/pages/library/partner/partner_detail.page.dart @@ -2,6 +2,7 @@ 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/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/entities/user.entity.dart'; @@ -22,7 +23,11 @@ class PartnerDetailPage extends HookConsumerWidget { useEffect( () { - ref.read(assetProvider.notifier).getAllAsset(); + Future.microtask( + () async => { + await ref.read(assetProvider.notifier).getAllAsset(), + }, + ); return null; }, [], @@ -64,19 +69,47 @@ class PartnerDetailPage extends HookConsumerWidget { title: Text(partner.name), elevation: 0, centerTitle: false, - actions: [ - IconButton( - onPressed: toggleInTimeline, - icon: Icon( - inTimeline.value - ? Icons.collections - : Icons.collections_outlined, + ), + body: MultiselectGrid( + topWidget: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ListTile( + title: Text( + "Show in timeline", + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.primary, ), - tooltip: "Show/hide photos on your main timeline", ), - ], + subtitle: Text( + "Show photos and videos from this user in your timeline", + style: context.textTheme.bodyMedium, + ), + trailing: Switch( + value: inTimeline.value, + onChanged: (_) => toggleInTimeline(), + ), + ), ), - body: MultiselectGrid( + ), + ), renderListProvider: assetsProvider(partner.isarId), onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(), deleteEnabled: false, diff --git a/mobile/lib/pages/library/people/people_collection.page.dart b/mobile/lib/pages/library/people/people_collection.page.dart new file mode 100644 index 00000000000000..b3f688280810cc --- /dev/null +++ b/mobile/lib/pages/library/people/people_collection.page.dart @@ -0,0 +1,104 @@ +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/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/search/people.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; + +@RoutePage() +class PeopleCollectionPage extends HookConsumerWidget { + const PeopleCollectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = ref.watch(getAllPeopleProvider); + final headers = ApiService.getRequestHeaders(); + + showNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ); + } + + return Scaffold( + appBar: AppBar( + title: Text('people'.tr()), + ), + body: people.when( + data: (people) { + return GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.85, + ), + padding: const EdgeInsets.symmetric(vertical: 32), + itemCount: people.length, + itemBuilder: (context, index) { + final person = people[index]; + + return Column( + children: [ + GestureDetector( + onTap: () { + context.pushRoute( + PersonResultRoute( + personId: person.id, + personName: person.name, + ), + ); + }, + child: Material( + shape: const CircleBorder(side: BorderSide.none), + elevation: 3, + child: CircleAvatar( + maxRadius: 96 / 2, + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: headers, + ), + ), + ), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () => showNameEditModel(person.id, person.name), + child: person.name.isEmpty + ? Text( + 'add_a_name'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.primary, + ), + ) + : Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + person.name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ); + }, + ); + }, + error: (error, stack) => const Text("error"), + loading: () => const CircularProgressIndicator(), + ), + ); + } +} diff --git a/mobile/lib/pages/library/places/places_collection.page.dart b/mobile/lib/pages/library/places/places_collection.page.dart new file mode 100644 index 00000000000000..3e4f9f6a1da010 --- /dev/null +++ b/mobile/lib/pages/library/places/places_collection.page.dart @@ -0,0 +1,125 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.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/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +@RoutePage() +class PlacesCollectionPage extends HookConsumerWidget { + const PlacesCollectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final places = ref.watch(getAllPlacesProvider); + + return Scaffold( + appBar: AppBar( + title: Text('places'.tr()), + ), + body: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: 200, + width: context.width, + child: MapThumbnail( + onTap: (_, __) => context.pushRoute(const MapRoute()), + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, + ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + places.when( + data: (places) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: places.length, + itemBuilder: (context, index) { + final place = places[index]; + + return PlaceTile(id: place.id, name: place.label); + }, + ); + }, + error: (error, stask) => const Text('Error getting places'), + loading: () => Center(child: const CircularProgressIndicator()), + ), + ], + ), + ); + } +} + +class PlaceTile extends StatelessWidget { + const PlaceTile({super.key, required this.id, required this.name}); + + final String id; + final String name; + + @override + Widget build(BuildContext context) { + final thumbnailUrl = + '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail'; + + void navigateToPlace() { + context.pushRoute( + SearchRoute( + prefilter: SearchFilter( + people: {}, + location: SearchLocationFilter( + city: name, + ), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, + ), + ), + ); + } + + return LargeLeadingTile( + onTap: () => navigateToPlace(), + title: Text( + name, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + leading: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: CachedNetworkImage( + width: 80, + height: 80, + fit: BoxFit.cover, + imageUrl: thumbnailUrl, + httpHeaders: ApiService.getRequestHeaders(), + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/sharing/shared_link/shared_link.page.dart b/mobile/lib/pages/library/shared_link/shared_link.page.dart similarity index 100% rename from mobile/lib/pages/sharing/shared_link/shared_link.page.dart rename to mobile/lib/pages/library/shared_link/shared_link.page.dart diff --git a/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart similarity index 100% rename from mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart rename to mobile/lib/pages/library/shared_link/shared_link_edit.page.dart diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 3c5ff272962a3b..14e5724155da3a 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -7,7 +7,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_lane.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; @@ -33,8 +32,7 @@ class PhotosPage extends HookConsumerWidget { () { ref.read(websocketProvider.notifier).connect(); Future(() => ref.read(assetProvider.notifier).getAllAsset()); - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + Future(() => ref.read(albumProvider.notifier).refreshRemoteAlbums()); ref.read(serverInfoProvider.notifier).getServerInfo(); return; }, diff --git a/mobile/lib/pages/search/person_result.page.dart b/mobile/lib/pages/search/person_result.page.dart index 55824b8db91f64..8627c65bcccef4 100644 --- a/mobile/lib/pages/search/person_result.page.dart +++ b/mobile/lib/pages/search/person_result.page.dart @@ -92,6 +92,7 @@ class PersonResultPage extends HookConsumerWidget { Text( name.value, style: context.textTheme.titleLarge, + overflow: TextOverflow.ellipsis, ), ], ), @@ -125,9 +126,11 @@ class PersonResultPage extends HookConsumerWidget { headers: ApiService.getRequestHeaders(), ), ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: buildTitleBlock(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: buildTitleBlock(), + ), ), ], ), diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 173115185bd5af..60e61da4cc5d52 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -1,254 +1,768 @@ -import 'dart:math' as math; - -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/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; -import 'package:immich_mobile/widgets/search/curated_people_row.dart'; -import 'package:immich_mobile/widgets/search/curated_places_row.dart'; -import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; -import 'package:immich_mobile/widgets/search/search_row_section.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; -import 'package:immich_mobile/widgets/common/scaffold_error_body.dart'; - -@RoutePage() -// ignore: must_be_immutable -class SearchPage extends HookConsumerWidget { - const SearchPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final places = ref.watch(getPreviewPlacesProvider); - final curatedPeople = ref.watch(getAllPeopleProvider); - final isMapEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map)); - final double imageSize = math.min(context.width / 3, 150); - - TextStyle categoryTitleStyle = const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 15.0, - ); - - Color categoryIconColor = context.colorScheme.onSurface; - - showNameEditModel( - String personId, - String personName, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return PersonNameEditForm(personId: personId, personName: personName); - }, - ); - } - - buildPeople() { - return curatedPeople.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) { - return SearchRowSection( - onViewAllPressed: () => context.pushRoute(const AllPeopleRoute()), - title: "search_page_people".tr(), - isEmpty: people.isEmpty, - child: CuratedPeopleRow( - padding: const EdgeInsets.symmetric(horizontal: 16), - content: people - .map((e) => SearchCuratedContent(label: e.name, id: e.id)) - .take(12) - .toList(), - onTap: (content, index) { - context.pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ); - }, - onNameTap: (person, index) => { - showNameEditModel(person.id, person.label), - }, - ), - ); - }, - ); - } - - buildPlaces() { - return places.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (data) { - return SearchRowSection( - onViewAllPressed: () => context.pushRoute(const AllPlacesRoute()), - title: "search_page_places".tr(), - isEmpty: !isMapEnabled && data.isEmpty, - child: CuratedPlacesRow( - isMapEnabled: isMapEnabled, - content: data, - imageSize: imageSize, - onTap: (content, index) { - context.pushRoute( - SearchInputRoute( - prefilter: SearchFilter( - people: {}, - location: SearchLocationFilter( - city: content.label, - ), - camera: SearchCameraFilter(), - date: SearchDateFilter(), - display: SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: AssetType.other, - ), - ), - ); - }, - ), - ); - }, - ); - } - - buildSearchButton() { - return GestureDetector( - onTap: () { - context.pushRoute(SearchInputRoute()); - }, - child: Card( - elevation: 0, - color: context.colorScheme.surfaceContainerHigh, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(50), - ), - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 12.0, - ), - child: Row( - children: [ - Icon( - Icons.search, - color: context.colorScheme.onSurfaceSecondary, - ), - const SizedBox(width: 16.0), - Text( - "search_bar_hint", - style: context.textTheme.bodyLarge?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.w400, - ), - ).tr(), - ], - ), - ), - ), - ); - } - - return Scaffold( - appBar: const ImmichAppBar(), - body: ListView( - children: [ - buildSearchButton(), - const SizedBox(height: 8.0), - buildPeople(), - const SizedBox(height: 8.0), - buildPlaces(), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'search_page_your_activity', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - leading: Icon( - Icons.favorite_border_rounded, - color: categoryIconColor, - ), - title: - Text('search_page_favorites', style: categoryTitleStyle).tr(), - onTap: () => context.pushRoute(const FavoritesRoute()), - ), - const CategoryDivider(), - ListTile( - leading: Icon( - Icons.schedule_outlined, - color: categoryIconColor, - ), - title: Text( - 'search_page_recently_added', - style: categoryTitleStyle, - ).tr(), - onTap: () => context.pushRoute(const RecentlyAddedRoute()), - ), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'search_page_categories', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ListTile( - title: Text('search_page_videos', style: categoryTitleStyle).tr(), - leading: Icon( - Icons.play_circle_outline, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllVideosRoute()), - ), - const CategoryDivider(), - ListTile( - title: Text( - 'search_page_motion_photos', - style: categoryTitleStyle, - ).tr(), - leading: Icon( - Icons.motion_photos_on_outlined, - color: categoryIconColor, - ), - onTap: () => context.pushRoute(const AllMotionPhotosRoute()), - ), - ], - ), - ); - } -} - -class CategoryDivider extends StatelessWidget { - const CategoryDivider({super.key}); - - @override - Widget build(BuildContext context) { - return const Padding( - padding: EdgeInsets.only( - left: 56, - right: 16, - ), - child: Divider( - height: 0, - ), - ); - } -} +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.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/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/providers/search/search_input_focus.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; +import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; +import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart'; +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'; + +@RoutePage() +class SearchPage extends HookConsumerWidget { + const SearchPage({super.key, this.prefilter}); + + final SearchFilter? prefilter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isContextualSearch = useState(true); + final textSearchController = useTextEditingController(); + final filter = useState( + SearchFilter( + people: prefilter?.people ?? {}, + location: prefilter?.location ?? SearchLocationFilter(), + camera: prefilter?.camera ?? SearchCameraFilter(), + date: prefilter?.date ?? SearchDateFilter(), + display: prefilter?.display ?? + SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: prefilter?.mediaType ?? AssetType.other, + ), + ); + + final previousFilter = useState(filter.value); + + final peopleCurrentFilterWidget = useState(null); + final dateRangeCurrentFilterWidget = useState(null); + final cameraCurrentFilterWidget = useState(null); + final locationCurrentFilterWidget = useState(null); + final mediaTypeCurrentFilterWidget = useState(null); + final displayOptionCurrentFilterWidget = useState(null); + + final currentPage = useState(1); + final searchProvider = ref.watch(paginatedSearchProvider); + final searchResultCount = useState(0); + + search() async { + if (prefilter == null && filter.value == previousFilter.value) return; + + ref.watch(paginatedSearchProvider.notifier).clear(); + + currentPage.value = 1; + + final searchResult = await ref + .watch(paginatedSearchProvider.notifier) + .getNextPage(filter.value, currentPage.value); + + previousFilter.value = filter.value; + searchResultCount.value = searchResult.length; + } + + searchPrefilter() { + if (prefilter != null) { + Future.delayed( + Duration.zero, + () { + search(); + + if (prefilter!.location.city != null) { + locationCurrentFilterWidget.value = Text( + prefilter!.location.city!, + style: context.textTheme.labelLarge, + ); + } + }, + ); + } + } + + useEffect( + () { + searchPrefilter(); + return null; + }, + [], + ); + + loadMoreSearchResult() async { + currentPage.value += 1; + final searchResult = await ref + .watch(paginatedSearchProvider.notifier) + .getNextPage(filter.value, currentPage.value); + searchResultCount.value = searchResult.length; + } + + showPeoplePicker() { + handleOnSelect(Set value) { + filter.value = filter.value.copyWith( + people: value, + ); + + peopleCurrentFilterWidget.value = Text( + value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + people: {}, + ); + + peopleCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_people_title'.tr(), + expanded: true, + onSearch: search, + onClear: handleClear, + child: PeoplePicker( + onSelect: handleOnSelect, + filter: filter.value.people, + ), + ), + ), + ); + } + + showLocationPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + location: SearchLocationFilter( + country: value['country'], + city: value['city'], + state: value['state'], + ), + ); + + final locationText = []; + if (value['country'] != null) { + locationText.add(value['country']!); + } + + if (value['state'] != null) { + locationText.add(value['state']!); + } + + if (value['city'] != null) { + locationText.add(value['city']!); + } + + locationCurrentFilterWidget.value = Text( + locationText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + location: SearchLocationFilter(), + ); + + locationCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + child: FilterBottomSheetScaffold( + title: 'search_filter_location_title'.tr(), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Container( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: LocationPicker( + onSelected: handleOnSelect, + filter: filter.value.location, + ), + ), + ), + ), + ), + ); + } + + showCameraPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter( + make: value['make'], + model: value['model'], + ), + ); + + cameraCurrentFilterWidget.value = Text( + '${value['make'] ?? ''} ${value['model'] ?? ''}', + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter(), + ); + + cameraCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: false, + child: FilterBottomSheetScaffold( + title: 'search_filter_camera_title'.tr(), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CameraPicker( + onSelect: handleOnSelect, + filter: filter.value.camera, + ), + ), + ), + ); + } + + showDatePicker() async { + final firstDate = DateTime(1900); + final lastDate = DateTime.now(); + + final date = await showDateRangePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + currentDate: DateTime.now(), + initialDateRange: DateTimeRange( + start: filter.value.date.takenAfter ?? lastDate, + end: filter.value.date.takenBefore ?? lastDate, + ), + helpText: 'search_filter_date_title'.tr(), + cancelText: 'action_common_cancel'.tr(), + confirmText: 'action_common_select'.tr(), + saveText: 'action_common_save'.tr(), + errorFormatText: 'invalid_date_format'.tr(), + errorInvalidText: 'invalid_date'.tr(), + fieldStartHintText: 'start_date'.tr(), + fieldEndHintText: 'end_date'.tr(), + initialEntryMode: DatePickerEntryMode.input, + ); + + if (date == null) { + filter.value = filter.value.copyWith( + date: SearchDateFilter(), + ); + + dateRangeCurrentFilterWidget.value = null; + search(); + return; + } + + filter.value = filter.value.copyWith( + date: SearchDateFilter( + takenAfter: date.start, + takenBefore: date.end.add( + const Duration( + hours: 23, + minutes: 59, + seconds: 59, + ), + ), + ), + ); + + // If date range is less than 24 hours, set the end date to the end of the day + if (date.end.difference(date.start).inHours < 24) { + dateRangeCurrentFilterWidget.value = Text( + DateFormat.yMMMd().format(date.start.toLocal()), + style: context.textTheme.labelLarge, + ); + } else { + dateRangeCurrentFilterWidget.value = Text( + 'search_filter_date_interval'.tr( + namedArgs: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ), + style: context.textTheme.labelLarge, + ); + } + + search(); + } + + // MEDIA PICKER + showMediaTypePicker() { + handleOnSelected(AssetType assetType) { + filter.value = filter.value.copyWith( + mediaType: assetType, + ); + + mediaTypeCurrentFilterWidget.value = Text( + assetType == AssetType.image + ? 'search_filter_media_type_image'.tr() + : assetType == AssetType.video + ? 'search_filter_media_type_video'.tr() + : 'search_filter_media_type_all'.tr(), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + mediaType: AssetType.other, + ); + + mediaTypeCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_media_type_title'.tr(), + onSearch: search, + onClear: handleClear, + child: MediaTypePicker( + onSelect: handleOnSelected, + filter: filter.value.mediaType, + ), + ), + ); + } + + // DISPLAY OPTION + showDisplayOptionPicker() { + handleOnSelect(Map value) { + final filterText = []; + value.forEach((key, value) { + switch (key) { + case DisplayOption.notInAlbum: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isNotInAlbum: value, + ), + ); + if (value) { + filterText + .add('search_filter_display_option_not_in_album'.tr()); + } + break; + case DisplayOption.archive: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isArchive: value, + ), + ); + if (value) { + filterText.add('search_filter_display_option_archive'.tr()); + } + break; + case DisplayOption.favorite: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isFavorite: value, + ), + ); + if (value) { + filterText.add('search_filter_display_option_favorite'.tr()); + } + break; + } + }); + + if (filterText.isEmpty) { + displayOptionCurrentFilterWidget.value = null; + return; + } + + displayOptionCurrentFilterWidget.value = Text( + filterText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + ); + + displayOptionCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_display_options_title'.tr(), + onSearch: search, + onClear: handleClear, + child: DisplayOptionPicker( + onSelect: handleOnSelect, + filter: filter.value.display, + ), + ), + ); + } + + handleTextSubmitted(String value) { + if (value.isEmpty) { + return; + } + + if (isContextualSearch.value) { + filter.value = filter.value.copyWith( + filename: null, + context: value, + ); + } else { + filter.value = filter.value.copyWith( + filename: value, + context: null, + ); + } + + search(); + } + + buildSearchResult() { + return switch (searchProvider) { + AsyncData() => Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: NotificationListener( + onNotification: (notification) { + final metrics = notification.metrics; + final shouldLoadMore = searchResultCount.value > 75; + if (metrics.pixels >= metrics.maxScrollExtent && + shouldLoadMore) { + loadMoreSearchResult(); + } + return true; + }, + child: MultiselectGrid( + renderListProvider: paginatedSearchRenderListProvider, + archiveEnabled: true, + deleteEnabled: true, + editEnabled: true, + favoriteEnabled: true, + stackEnabled: false, + emptyIndicator: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SearchEmptyContent(), + ), + ), + ), + ), + ), + AsyncError(:final error) => Text('Error: $error'), + _ => const Expanded(child: Center(child: CircularProgressIndicator())), + }; + } + + return Scaffold( + resizeToAvoidBottomInset: true, + appBar: AppBar( + automaticallyImplyLeading: true, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 14.0), + child: IconButton( + icon: isContextualSearch.value + ? const Icon(Icons.abc_rounded) + : const Icon(Icons.image_search_rounded), + onPressed: () { + isContextualSearch.value = !isContextualSearch.value; + textSearchController.clear(); + }, + ), + ), + ], + title: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withOpacity(0.075), + context.colorScheme.primary.withOpacity(0.09), + context.colorScheme.primary.withOpacity(0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: TextField( + controller: textSearchController, + decoration: InputDecoration( + contentPadding: prefilter != null + ? EdgeInsets.only(left: 24) + : EdgeInsets.all(8), + prefixIcon: prefilter != null + ? null + : Icon( + Icons.search_rounded, + color: context.colorScheme.primary, + ), + hintText: isContextualSearch.value + ? 'contextual_search'.tr() + : 'filename_search'.tr(), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.themeData.colorScheme.onSurfaceSecondary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainer, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(100), + ), + ), + ), + onSubmitted: handleTextSubmitted, + focusNode: ref.watch(searchInputFocusProvider), + onTapOutside: (_) => ref.read(searchInputFocusProvider).unfocus(), + ), + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: SizedBox( + height: 50, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + SearchFilterChip( + icon: Icons.people_alt_rounded, + onTap: showPeoplePicker, + label: 'search_filter_people'.tr(), + currentFilter: peopleCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.location_pin, + onTap: showLocationPicker, + label: 'search_filter_location'.tr(), + currentFilter: locationCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.camera_alt_rounded, + onTap: showCameraPicker, + label: 'search_filter_camera'.tr(), + currentFilter: cameraCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.date_range_rounded, + onTap: showDatePicker, + label: 'search_filter_date'.tr(), + currentFilter: dateRangeCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.video_collection_outlined, + onTap: showMediaTypePicker, + label: 'search_filter_media_type'.tr(), + currentFilter: mediaTypeCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.display_settings_outlined, + onTap: showDisplayOptionPicker, + label: 'search_filter_display_options'.tr(), + currentFilter: displayOptionCurrentFilterWidget.value, + ), + ], + ), + ), + ), + buildSearchResult(), + ], + ), + ); + } +} + +class SearchEmptyContent extends StatelessWidget { + const SearchEmptyContent({super.key}); + + @override + Widget build(BuildContext context) { + return ListView( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + children: [ + SizedBox(height: 40), + Center( + child: Image.asset( + context.isDarkTheme + ? 'assets/polaroid-dark.png' + : 'assets/polaroid-light.png', + height: 125, + ), + ), + SizedBox(height: 16), + Center( + child: Text( + "Search for your photos and videos", + style: context.textTheme.labelLarge, + ), + ), + SizedBox(height: 32), + QuickLinkList(), + ], + ); + } +} + +class QuickLinkList extends StatelessWidget { + const QuickLinkList({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: context.colorScheme.outline.withAlpha(10), + width: 1, + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + context.colorScheme.primary.withAlpha(20), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + QuickLink( + title: 'recently_added'.tr(), + icon: Icons.schedule_outlined, + isTop: true, + onTap: () => context.pushRoute(const RecentlyAddedRoute()), + ), + QuickLink( + title: 'videos'.tr(), + icon: Icons.play_circle_outline_rounded, + onTap: () => context.pushRoute(AllVideosRoute()), + ), + QuickLink( + title: 'favorites'.tr(), + icon: Icons.favorite_border_rounded, + isBottom: true, + onTap: () => context.pushRoute(FavoritesRoute()), + ), + ], + ), + ); + } +} + +class QuickLink extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + final bool isTop; + final bool isBottom; + + const QuickLink({ + super.key, + required this.title, + required this.icon, + required this.onTap, + this.isTop = false, + this.isBottom = false, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.only( + topLeft: Radius.circular(isTop ? 20 : 0), + topRight: Radius.circular(isTop ? 20 : 0), + bottomLeft: Radius.circular(isBottom ? 20 : 0), + bottomRight: Radius.circular(isBottom ? 20 : 0), + ); + + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + ), + leading: Icon( + icon, + size: 26, + ), + title: Text( + title, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + onTap: onTap, + ); + } +} diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart deleted file mode 100644 index 2ca2a379180dd2..00000000000000 --- a/mobile/lib/pages/search/search_input.page.dart +++ /dev/null @@ -1,582 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.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/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'; -import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; -import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart'; -import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart'; -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'; - -@RoutePage() -class SearchInputPage extends HookConsumerWidget { - const SearchInputPage({super.key, this.prefilter}); - - final SearchFilter? prefilter; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isContextualSearch = useState(true); - final textSearchController = useTextEditingController(); - final filter = useState( - SearchFilter( - people: prefilter?.people ?? {}, - location: prefilter?.location ?? SearchLocationFilter(), - camera: prefilter?.camera ?? SearchCameraFilter(), - date: prefilter?.date ?? SearchDateFilter(), - display: prefilter?.display ?? - SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: prefilter?.mediaType ?? AssetType.other, - ), - ); - - final previousFilter = useState(filter.value); - - final peopleCurrentFilterWidget = useState(null); - final dateRangeCurrentFilterWidget = useState(null); - final cameraCurrentFilterWidget = useState(null); - final locationCurrentFilterWidget = useState(null); - final mediaTypeCurrentFilterWidget = useState(null); - final displayOptionCurrentFilterWidget = useState(null); - - final currentPage = useState(1); - final searchProvider = ref.watch(paginatedSearchProvider); - final searchResultCount = useState(0); - - search() async { - if (prefilter == null && filter.value == previousFilter.value) return; - - ref.watch(paginatedSearchProvider.notifier).clear(); - - currentPage.value = 1; - - final searchResult = await ref - .watch(paginatedSearchProvider.notifier) - .getNextPage(filter.value, currentPage.value); - previousFilter.value = filter.value; - - searchResultCount.value = searchResult.length; - } - - searchPrefilter() { - if (prefilter != null) { - Future.delayed( - Duration.zero, - () { - search(); - - if (prefilter!.location.city != null) { - locationCurrentFilterWidget.value = Text( - prefilter!.location.city!, - style: context.textTheme.labelLarge, - ); - } - }, - ); - } - } - - useEffect( - () { - searchPrefilter(); - return null; - }, - [], - ); - - loadMoreSearchResult() async { - currentPage.value += 1; - final searchResult = await ref - .watch(paginatedSearchProvider.notifier) - .getNextPage(filter.value, currentPage.value); - searchResultCount.value = searchResult.length; - } - - showPeoplePicker() { - handleOnSelect(Set value) { - filter.value = filter.value.copyWith( - people: value, - ); - - peopleCurrentFilterWidget.value = Text( - value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).join(', '), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - people: {}, - ); - - peopleCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - isScrollControlled: true, - child: FractionallySizedBox( - heightFactor: 0.8, - child: FilterBottomSheetScaffold( - title: 'search_filter_people_title'.tr(), - expanded: true, - onSearch: search, - onClear: handleClear, - child: PeoplePicker( - onSelect: handleOnSelect, - filter: filter.value.people, - ), - ), - ), - ); - } - - showLocationPicker() { - handleOnSelect(Map value) { - filter.value = filter.value.copyWith( - location: SearchLocationFilter( - country: value['country'], - city: value['city'], - state: value['state'], - ), - ); - - final locationText = []; - if (value['country'] != null) { - locationText.add(value['country']!); - } - - if (value['state'] != null) { - locationText.add(value['state']!); - } - - if (value['city'] != null) { - locationText.add(value['city']!); - } - - locationCurrentFilterWidget.value = Text( - locationText.join(', '), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - location: SearchLocationFilter(), - ); - - locationCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: false, - child: FilterBottomSheetScaffold( - title: 'search_filter_location_title'.tr(), - onSearch: search, - onClear: handleClear, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: LocationPicker( - onSelected: handleOnSelect, - filter: filter.value.location, - ), - ), - ), - ), - ), - ); - } - - showCameraPicker() { - handleOnSelect(Map value) { - filter.value = filter.value.copyWith( - camera: SearchCameraFilter( - make: value['make'], - model: value['model'], - ), - ); - - cameraCurrentFilterWidget.value = Text( - '${value['make'] ?? ''} ${value['model'] ?? ''}', - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - camera: SearchCameraFilter(), - ); - - cameraCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - isScrollControlled: true, - isDismissible: false, - child: FilterBottomSheetScaffold( - title: 'search_filter_camera_title'.tr(), - onSearch: search, - onClear: handleClear, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: CameraPicker( - onSelect: handleOnSelect, - filter: filter.value.camera, - ), - ), - ), - ); - } - - showDatePicker() async { - final firstDate = DateTime(1900); - final lastDate = DateTime.now(); - - final date = await showDateRangePicker( - context: context, - firstDate: firstDate, - lastDate: lastDate, - currentDate: DateTime.now(), - initialDateRange: DateTimeRange( - start: filter.value.date.takenAfter ?? lastDate, - end: filter.value.date.takenBefore ?? lastDate, - ), - helpText: 'search_filter_date_title'.tr(), - cancelText: 'action_common_cancel'.tr(), - confirmText: 'action_common_select'.tr(), - saveText: 'action_common_save'.tr(), - errorFormatText: 'invalid_date_format'.tr(), - errorInvalidText: 'invalid_date'.tr(), - fieldStartHintText: 'start_date'.tr(), - fieldEndHintText: 'end_date'.tr(), - initialEntryMode: DatePickerEntryMode.input, - ); - - if (date == null) { - filter.value = filter.value.copyWith( - date: SearchDateFilter(), - ); - - dateRangeCurrentFilterWidget.value = null; - search(); - return; - } - - filter.value = filter.value.copyWith( - date: SearchDateFilter( - takenAfter: date.start, - takenBefore: date.end.add( - const Duration( - hours: 23, - minutes: 59, - seconds: 59, - ), - ), - ), - ); - - // If date range is less than 24 hours, set the end date to the end of the day - if (date.end.difference(date.start).inHours < 24) { - dateRangeCurrentFilterWidget.value = Text( - DateFormat.yMMMd().format(date.start.toLocal()), - style: context.textTheme.labelLarge, - ); - } else { - dateRangeCurrentFilterWidget.value = Text( - 'search_filter_date_interval'.tr( - namedArgs: { - "start": DateFormat.yMMMd().format(date.start.toLocal()), - "end": DateFormat.yMMMd().format(date.end.toLocal()), - }, - ), - style: context.textTheme.labelLarge, - ); - } - - search(); - } - - // MEDIA PICKER - showMediaTypePicker() { - handleOnSelected(AssetType assetType) { - filter.value = filter.value.copyWith( - mediaType: assetType, - ); - - mediaTypeCurrentFilterWidget.value = Text( - assetType == AssetType.image - ? 'search_filter_media_type_image'.tr() - : assetType == AssetType.video - ? 'search_filter_media_type_video'.tr() - : 'search_filter_media_type_all'.tr(), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - mediaType: AssetType.other, - ); - - mediaTypeCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - child: FilterBottomSheetScaffold( - title: 'search_filter_media_type_title'.tr(), - onSearch: search, - onClear: handleClear, - child: MediaTypePicker( - onSelect: handleOnSelected, - filter: filter.value.mediaType, - ), - ), - ); - } - - // DISPLAY OPTION - showDisplayOptionPicker() { - handleOnSelect(Map value) { - final filterText = []; - - value.forEach((key, value) { - switch (key) { - case DisplayOption.notInAlbum: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isNotInAlbum: value, - ), - ); - if (value) { - filterText - .add('search_filter_display_option_not_in_album'.tr()); - } - break; - case DisplayOption.archive: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isArchive: value, - ), - ); - if (value) { - filterText.add('search_filter_display_option_archive'.tr()); - } - break; - case DisplayOption.favorite: - filter.value = filter.value.copyWith( - display: filter.value.display.copyWith( - isFavorite: value, - ), - ); - if (value) { - filterText.add('search_filter_display_option_favorite'.tr()); - } - break; - } - }); - - displayOptionCurrentFilterWidget.value = Text( - filterText.join(', '), - style: context.textTheme.labelLarge, - ); - } - - handleClear() { - filter.value = filter.value.copyWith( - display: SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - ); - - displayOptionCurrentFilterWidget.value = null; - search(); - } - - showFilterBottomSheet( - context: context, - child: FilterBottomSheetScaffold( - title: 'search_filter_display_options_title'.tr(), - onSearch: search, - onClear: handleClear, - child: DisplayOptionPicker( - onSelect: handleOnSelect, - filter: filter.value.display, - ), - ), - ); - } - - handleTextSubmitted(String value) { - if (isContextualSearch.value) { - filter.value = filter.value.copyWith( - context: value, - filename: null, - ); - } else { - filter.value = filter.value.copyWith(filename: value, context: null); - } - - search(); - } - - buildSearchResult() { - return switch (searchProvider) { - AsyncData() => Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: NotificationListener( - onNotification: (notification) { - final metrics = notification.metrics; - final shouldLoadMore = searchResultCount.value > 75; - if (metrics.pixels >= metrics.maxScrollExtent && - shouldLoadMore) { - loadMoreSearchResult(); - } - return true; - }, - child: MultiselectGrid( - renderListProvider: paginatedSearchRenderListProvider, - archiveEnabled: true, - deleteEnabled: true, - editEnabled: true, - favoriteEnabled: true, - stackEnabled: false, - emptyIndicator: const SizedBox(), - ), - ), - ), - ), - AsyncError(:final error) => Text('Error: $error'), - _ => const Expanded(child: Center(child: CircularProgressIndicator())), - }; - } - - return Scaffold( - resizeToAvoidBottomInset: true, - appBar: AppBar( - automaticallyImplyLeading: true, - actions: [ - IconButton( - icon: isContextualSearch.value - ? const Icon(Icons.abc_rounded) - : const Icon(Icons.image_search_rounded), - onPressed: () { - isContextualSearch.value = !isContextualSearch.value; - textSearchController.clear(); - }, - ), - ], - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: () => context.router.maybePop(), - ), - title: TextField( - controller: textSearchController, - decoration: InputDecoration( - hintText: isContextualSearch.value - ? 'contextual_search'.tr() - : 'filename_search'.tr(), - hintStyle: context.textTheme.bodyLarge?.copyWith( - color: context.themeData.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.w500, - ), - enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), - ), - focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), - ), - ), - onSubmitted: handleTextSubmitted, - ), - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 12.0), - child: SizedBox( - height: 50, - child: ListView( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - SearchFilterChip( - icon: Icons.people_alt_rounded, - onTap: showPeoplePicker, - label: 'search_filter_people'.tr(), - currentFilter: peopleCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.location_pin, - onTap: showLocationPicker, - label: 'search_filter_location'.tr(), - currentFilter: locationCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.camera_alt_rounded, - onTap: showCameraPicker, - label: 'search_filter_camera'.tr(), - currentFilter: cameraCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.date_range_rounded, - onTap: showDatePicker, - label: 'search_filter_date'.tr(), - currentFilter: dateRangeCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.video_collection_outlined, - onTap: showMediaTypePicker, - label: 'search_filter_media_type'.tr(), - currentFilter: mediaTypeCurrentFilterWidget.value, - ), - SearchFilterChip( - icon: Icons.display_settings_outlined, - onTap: showDisplayOptionPicker, - label: 'search_filter_display_options'.tr(), - currentFilter: displayOptionCurrentFilterWidget.value, - ), - ], - ), - ), - ), - buildSearchResult(), - ], - ), - ); - } -} diff --git a/mobile/lib/pages/sharing/sharing.page.dart b/mobile/lib/pages/sharing/sharing.page.dart deleted file mode 100644 index 98d4cfafe9fe56..00000000000000 --- a/mobile/lib/pages/sharing/sharing.page.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; -import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; -import 'package:immich_mobile/providers/partner.provider.dart'; -import 'package:immich_mobile/widgets/partner/partner_list.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; -import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; - -@RoutePage() -class SharingPage extends HookConsumerWidget { - const SharingPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final albumSortOption = ref.watch(albumSortByOptionsProvider); - final albumSortIsReverse = ref.watch(albumSortOrderProvider); - final albums = ref.watch(sharedAlbumProvider); - final sharedAlbums = albumSortOption.sortFn(albums, albumSortIsReverse); - final userId = ref.watch(currentUserProvider)?.id; - final partner = ref.watch(partnerSharedWithProvider); - - useEffect( - () { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - return null; - }, - [], - ); - - buildAlbumGrid() { - return SliverPadding( - padding: const EdgeInsets.all(18.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - return AlbumThumbnailCard( - album: sharedAlbums[index], - showOwner: true, - onTap: () => context.pushRoute( - AlbumViewerRoute(albumId: sharedAlbums[index].id), - ), - ); - }, - childCount: sharedAlbums.length, - ), - ), - ); - } - - buildAlbumList() { - return SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - final album = sharedAlbums[index]; - final isOwner = album.ownerId == userId; - - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - leading: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: ImmichThumbnail( - asset: album.thumbnail.value, - width: 60, - height: 60, - ), - ), - title: Text( - album.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - subtitle: isOwner - ? Text( - 'album_thumbnail_owned'.tr(), - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ) - : album.ownerName != null - ? Text( - 'album_thumbnail_shared_by' - .tr(args: [album.ownerName!]), - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ) - : null, - onTap: () => context - .pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)), - ); - }, - childCount: sharedAlbums.length, - ), - ); - } - - buildTopBottons() { - return Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 12.0, - top: 24.0, - bottom: 12.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => - context.pushRoute(CreateAlbumRoute(isSharedAlbum: true)), - icon: const Icon( - Icons.photo_album_outlined, - size: 20, - ), - label: const Text( - "sharing_silver_appbar_create_shared_album", - maxLines: 1, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ).tr(), - ), - ), - const SizedBox(width: 12.0), - Expanded( - child: ElevatedButton.icon( - onPressed: () => context.pushRoute(const SharedLinkRoute()), - icon: const Icon( - Icons.link, - size: 20, - ), - label: const Text( - "sharing_silver_appbar_shared_links", - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - maxLines: 1, - ).tr(), - ), - ), - ], - ), - ); - } - - buildEmptyListIndication() { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(20)), - side: BorderSide( - color: context.isDarkTheme - ? const Color(0xFF383838) - : Colors.black12, - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(18.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 5.0, bottom: 5), - child: Icon( - Icons.insert_photo_rounded, - size: 50, - color: context.primaryColor, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'sharing_page_empty_list', - style: context.textTheme.displaySmall, - ).tr(), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'sharing_page_description', - style: context.textTheme.bodyMedium, - ).tr(), - ), - ], - ), - ), - ), - ), - ); - } - - Widget sharePartnerButton() { - return InkWell( - onTap: () => context.pushRoute(const PartnerRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Icon( - Icons.swap_horizontal_circle_rounded, - size: 25, - semanticLabel: 'partner_page_title'.tr(), - ), - ); - } - - return RefreshIndicator( - onRefresh: () async { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - }, - child: Scaffold( - appBar: ImmichAppBar( - action: sharePartnerButton(), - ), - body: CustomScrollView( - slivers: [ - SliverToBoxAdapter(child: buildTopBottons()), - if (partner.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: Text( - "partner_page_title", - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ), - if (partner.isNotEmpty) PartnerList(partner: partner), - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: Text( - "sharing_page_album", - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ), - SliverLayoutBuilder( - builder: (context, constraints) { - if (sharedAlbums.isEmpty) { - return buildEmptyListIndication(); - } - - if (constraints.crossAxisExtent < 600) { - return buildAlbumList(); - } else { - return buildAlbumGrid(); - } - }, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index ed9dc07f5e5c04..53c8855c0a9cf5 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -1,21 +1,21 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/album.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/utils/renderlist_generator.dart'; import 'package:isar/isar.dart'; +final isRefreshingRemoteAlbumProvider = StateProvider((ref) => false); + class AlbumNotifier extends StateNotifier> { - AlbumNotifier(this._albumService, Isar db) : super([]) { - final query = db.albums - .filter() - .owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)); + AlbumNotifier(this._albumService, this.db, this.ref) : super([]) { + final query = db.albums.filter().remoteIdIsNotNull(); query.findAll().then((value) { if (mounted) { state = value; @@ -25,14 +25,17 @@ class AlbumNotifier extends StateNotifier> { } final AlbumService _albumService; + final Isar db; + final Ref ref; late final StreamSubscription> _streamSub; - Future getAllAlbums() => Future.wait([ - _albumService.refreshDeviceAlbums(), - _albumService.refreshRemoteAlbums(isShared: false), - ]); + Future refreshRemoteAlbums() async { + ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true; + await _albumService.refreshRemoteAlbums(); + ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false; + } - Future getDeviceAlbums() => _albumService.refreshDeviceAlbums(); + Future refreshDeviceAlbums() => _albumService.refreshDeviceAlbums(); Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); @@ -59,6 +62,50 @@ class AlbumNotifier extends StateNotifier> { await createAlbum(albumName, {}); } + Future leaveAlbum(Album album) async { + var res = await _albumService.leaveAlbum(album); + + if (res) { + await deleteAlbum(album); + return true; + } else { + return false; + } + } + + void searchAlbums(String searchTerm, QuickFilterMode filterMode) async { + state = await _albumService.search(searchTerm, filterMode); + } + + Future addUsers(Album album, List userIds) async { + await _albumService.addUsers(album, userIds); + } + + Future removeUser(Album album, User user) async { + final isRemoved = await _albumService.removeUser(album, user); + + if (isRemoved && album.sharedUsers.isEmpty) { + state = state.where((element) => element.id != album.id).toList(); + } + + return isRemoved; + } + + Future addAssets(Album album, Iterable assets) async { + await _albumService.addAssets(album, assets); + } + + Future removeAsset(Album album, Iterable assets) async { + return await _albumService.removeAsset(album, assets); + } + + Future setActivitystatus( + Album album, + bool enabled, + ) { + return _albumService.setActivityStatus(album, enabled); + } + @override void dispose() { _streamSub.cancel(); @@ -71,6 +118,7 @@ final albumProvider = return AlbumNotifier( ref.watch(albumServiceProvider), ref.watch(dbProvider), + ref, ); }); @@ -94,3 +142,31 @@ final albumRenderlistProvider = } return const Stream.empty(); }); + +class LocalAlbumsNotifier extends StateNotifier> { + LocalAlbumsNotifier(this.db) : super([]) { + final query = db.albums.where().remoteIdIsNull(); + + query.findAll().then((value) { + if (mounted) { + state = value; + } + }); + + _streamSub = query.watch().listen((data) => state = data); + } + + final Isar db; + late final StreamSubscription> _streamSub; + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); + } +} + +final localAlbumsProvider = + StateNotifierProvider.autoDispose>((ref) { + return LocalAlbumsNotifier(ref.watch(dbProvider)); +}); diff --git a/mobile/lib/providers/album/album_viewer.provider.dart b/mobile/lib/providers/album/album_viewer.provider.dart index f34ff4ef2257ef..e41865778214ad 100644 --- a/mobile/lib/providers/album/album_viewer.provider.dart +++ b/mobile/lib/providers/album/album_viewer.provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/albums/album_viewer_page_state.model.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -40,7 +39,6 @@ class AlbumViewerNotifier extends StateNotifier { if (isSuccess) { state = state.copyWith(editTitleText: "", isEditAlbum: false); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); return true; } diff --git a/mobile/lib/providers/album/shared_album.provider.dart b/mobile/lib/providers/album/shared_album.provider.dart deleted file mode 100644 index 0d581353757b82..00000000000000 --- a/mobile/lib/providers/album/shared_album.provider.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/album.service.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/providers/db.provider.dart'; -import 'package:isar/isar.dart'; - -class SharedAlbumNotifier extends StateNotifier> { - SharedAlbumNotifier(this._albumService, Isar db) : super([]) { - final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc(); - query.findAll().then((value) { - if (mounted) { - state = value; - } - }); - _streamSub = query.watch().listen((data) => state = data); - } - - final AlbumService _albumService; - late final StreamSubscription> _streamSub; - - Future createSharedAlbum( - String albumName, - Iterable assets, - Iterable sharedUsers, - ) async { - try { - return await _albumService.createAlbum( - albumName, - assets, - sharedUsers, - ); - } catch (e) { - debugPrint("Error createSharedAlbum ${e.toString()}"); - } - return null; - } - - Future getAllSharedAlbums() => - _albumService.refreshRemoteAlbums(isShared: true); - - Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); - - Future leaveAlbum(Album album) async { - var res = await _albumService.leaveAlbum(album); - - if (res) { - await deleteAlbum(album); - return true; - } else { - return false; - } - } - - Future removeAssetFromAlbum(Album album, Iterable assets) { - return _albumService.removeAssetFromAlbum(album, assets); - } - - Future removeUserFromAlbum(Album album, User user) async { - final result = await _albumService.removeUserFromAlbum(album, user); - - if (result && album.sharedUsers.isEmpty) { - state = state.where((element) => element.id != album.id).toList(); - } - - return result; - } - - Future setActivityEnabled(Album album, bool activityEnabled) { - return _albumService.setActivityEnabled(album, activityEnabled); - } - - @override - void dispose() { - _streamSub.cancel(); - super.dispose(); - } -} - -final sharedAlbumProvider = - StateNotifierProvider.autoDispose>((ref) { - return SharedAlbumNotifier( - ref.watch(albumServiceProvider), - ref.watch(dbProvider), - ); -}); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 5561d3fefd6835..c06a99da35b62c 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -58,11 +57,10 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(assetProvider.notifier).getAllAsset(); case TabEnum.search: // nothing to do - case TabEnum.sharing: - _ref.read(assetProvider.notifier).getAllAsset(); - _ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + case TabEnum.albums: + _ref.read(albumProvider.notifier).refreshRemoteAlbums(); case TabEnum.library: - _ref.read(albumProvider.notifier).getAllAlbums(); + // nothing to do } } diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index b56e71b11b3f6d..1fe7db5d46f42b 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; import 'package:immich_mobile/entities/user.entity.dart'; @@ -115,7 +114,6 @@ class AuthenticationNotifier extends StateNotifier { Store.delete(StoreKey.accessToken), ]); _ref.invalidate(albumProvider); - _ref.invalidate(sharedAlbumProvider); state = state.copyWith( deviceId: "", diff --git a/mobile/lib/providers/backup/backup_verification.provider.dart b/mobile/lib/providers/backup/backup_verification.provider.dart index 894b807ec87596..7b8e7b8c4b6d00 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/backup_verification.provider.g.dart b/mobile/lib/providers/backup/backup_verification.provider.g.dart index f222c9bd83e125..e286f434219b53 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.g.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.g.dart @@ -7,7 +7,7 @@ part of 'backup_verification.provider.dart'; // ************************************************************************** String _$backupVerificationHash() => - r'b691e0cc27856eef189258d3c102cc73ce4812a4'; + r'021dfdf65e1903c932e4a1c14967b786dd3516fb'; /// See also [BackupVerification]. @ProviderFor(BackupVerification) diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index c1bafa6c5a0ba4..bbfaf12a4f445d 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -7,8 +7,6 @@ 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:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset @@ -19,12 +17,6 @@ class ImmichLocalImageProvider extends ImageProvider { required this.asset, }) : assert(asset.local != null, 'Only usable when asset.local is set'); - /// Whether to show the original file or load a compressed version - bool get _useOriginal => Store.get( - AppSettingsEnum.loadOriginal.storeKey, - AppSettingsEnum.loadOriginal.defaultValue, - ); - /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// that describes the precise image to load. @override @@ -68,34 +60,16 @@ class ImmichLocalImageProvider extends ImageProvider { } if (asset.isImage) { - /// Using 2K thumbnail for local iOS image to avoid double swiping issue - if (Platform.isIOS) { - final largeImageBytes = _useOriginal - ? await asset.local?.originBytes - : await asset.local - ?.thumbnailDataWithSize(const ThumbnailSize(3840, 2160)); - - if (largeImageBytes == null) { - throw StateError( - "Loading thumb for local photo ${asset.fileName} failed", - ); - } - final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes); + final File? file = await asset.local?.originFile; + if (file == null) { + throw StateError("Opening file for asset ${asset.fileName} failed"); + } + try { + final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); final codec = await decode(buffer); yield codec; - } else { - // Use the original file for Android - final File? file = await asset.local?.originFile; - if (file == null) { - throw StateError("Opening file for asset ${asset.fileName} failed"); - } - try { - final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); - final codec = await decode(buffer); - yield codec; - } catch (error) { - throw StateError("Loading asset ${asset.fileName} failed"); - } + } catch (error) { + throw StateError("Loading asset ${asset.fileName} failed"); } } diff --git a/mobile/lib/providers/search/search_input_focus.provider.dart b/mobile/lib/providers/search/search_input_focus.provider.dart new file mode 100644 index 00000000000000..4f6ed41ee055fb --- /dev/null +++ b/mobile/lib/providers/search/search_input_focus.provider.dart @@ -0,0 +1,6 @@ +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final searchInputFocusProvider = Provider((ref) { + return FocusNode(); +}); diff --git a/mobile/lib/providers/tab.provider.dart b/mobile/lib/providers/tab.provider.dart index 2abed7c395e50b..a4875115ce2a03 100644 --- a/mobile/lib/providers/tab.provider.dart +++ b/mobile/lib/providers/tab.provider.dart @@ -1,11 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -enum TabEnum { - home, - search, - sharing, - library, -} +enum TabEnum { home, search, albums, library } /// Provides the currently active tab final tabProvider = StateProvider( diff --git a/mobile/lib/providers/trash.provider.dart b/mobile/lib/providers/trash.provider.dart index 45ab1a518583e1..8bbac853c7baba 100644 --- a/mobile/lib/providers/trash.provider.dart +++ b/mobile/lib/providers/trash.provider.dart @@ -167,6 +167,6 @@ final trashedAssetsProvider = StreamProvider((ref) { .filter() .ownerIdEqualTo(user.isarId) .isTrashedEqualTo(true) - .sortByFileCreatedAt(); + .sortByFileCreatedAtDesc(); return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none); }); diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 35f5cae32722c3..2c78e4c2389f14 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -1,8 +1,10 @@ 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/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; @@ -118,4 +120,33 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { @override Future deleteAllLocal() => txn(() => db.albums.where().localIdIsNotNull().deleteAll()); + + @override + Future> search( + String searchTerm, + QuickFilterMode filterMode, + ) async { + var query = db.albums + .filter() + .nameContains(searchTerm, caseSensitive: false) + .remoteIdIsNotNull(); + + switch (filterMode) { + case QuickFilterMode.sharedWithMe: + query = query.owner( + (q) => q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), + ); + break; + case QuickFilterMode.myAlbums: + query = query.owner( + (q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), + ); + break; + case QuickFilterMode.all: + default: + break; + } + + return await query.findAll(); + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart index 0b3d164ca35236..1ae16d9d52993f 100644 --- a/mobile/lib/repositories/partner_api.repository.dart +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -36,7 +36,7 @@ class PartnerApiRepository extends ApiRepository } @override - Future delete(String id) => checkNull(_api.removePartner(id)); + Future delete(String id) => _api.removePartner(id); @override Future update(String id, {required bool inTimeline}) async { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 6869e7b7047e9d..b001c6bdd6d334 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -13,6 +13,11 @@ import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; +import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; +import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; +import 'package:immich_mobile/pages/library/library.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/common/album_asset_selection.page.dart'; @@ -29,9 +34,9 @@ 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'; import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; @@ -47,12 +52,10 @@ import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart'; import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_added.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; -import 'package:immich_mobile/pages/search/search_input.page.dart'; -import 'package:immich_mobile/pages/sharing/partner/partner.page.dart'; -import 'package:immich_mobile/pages/sharing/partner/partner_detail.page.dart'; -import 'package:immich_mobile/pages/sharing/shared_link/shared_link.page.dart'; -import 'package:immich_mobile/pages/sharing/shared_link/shared_link_edit.page.dart'; -import 'package:immich_mobile/pages/sharing/sharing.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -93,6 +96,11 @@ class AppRouter extends RootStackRouter { ), AutoRoute(page: LoginRoute.page, guards: [_duplicateGuard]), AutoRoute(page: ChangePasswordRoute.page), + AutoRoute( + page: SearchRoute.page, + guards: [_authGuard, _duplicateGuard], + maintainState: false, + ), CustomRoute( page: TabControllerRoute.page, guards: [_authGuard, _duplicateGuard], @@ -104,13 +112,14 @@ class AppRouter extends RootStackRouter { AutoRoute( page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], + maintainState: false, ), AutoRoute( - page: SharingRoute.page, + page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard], ), AutoRoute( - page: LibraryRoute.page, + page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard], ), ], @@ -135,7 +144,12 @@ class AppRouter extends RootStackRouter { ), AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), - AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: FilterImageRoute.page), + CustomRoute( + page: FavoritesRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute( page: AllMotionPhotosRoute.page, @@ -181,8 +195,16 @@ class AppRouter extends RootStackRouter { AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]), - AutoRoute(page: ArchiveRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]), + CustomRoute( + page: ArchiveRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PartnerRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute( page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard], @@ -198,10 +220,15 @@ class AppRouter extends RootStackRouter { page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard], ), - AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute( + CustomRoute( + page: TrashRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, ), AutoRoute( page: SharedLinkEditRoute.page, @@ -221,15 +248,30 @@ class AppRouter extends RootStackRouter { page: BackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard], ), - CustomRoute( - page: SearchInputRoute.page, - guards: [_authGuard, _duplicateGuard], - transitionsBuilder: TransitionsBuilders.noTransition, - ), AutoRoute( page: HeaderSettingsRoute.page, guards: [_duplicateGuard], ), + CustomRoute( + page: PeopleCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: AlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: LocalAlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PlacesCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index df4c29fba1c708..ea7d385e85626c 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -319,6 +319,25 @@ class AlbumViewerRouteArgs { } } +/// generated route for +/// [AlbumsPage] +class AlbumsRoute extends PageRouteInfo { + const AlbumsRoute({List? children}) + : super( + AlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'AlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AlbumsPage(); + }, + ); +} + /// generated route for /// [AllMotionPhotosPage] class AllMotionPhotosRoute extends PageRouteInfo { @@ -560,15 +579,13 @@ class ChangePasswordRoute extends PageRouteInfo { class CreateAlbumRoute extends PageRouteInfo { CreateAlbumRoute({ Key? key, - required bool isSharedAlbum, - List? initialAssets, + List? assets, List? children, }) : super( CreateAlbumRoute.name, args: CreateAlbumRouteArgs( key: key, - isSharedAlbum: isSharedAlbum, - initialAssets: initialAssets, + assets: assets, ), initialChildren: children, ); @@ -578,11 +595,11 @@ class CreateAlbumRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs(); + final args = data.argsAs( + orElse: () => const CreateAlbumRouteArgs()); return CreateAlbumPage( key: args.key, - isSharedAlbum: args.isSharedAlbum, - initialAssets: args.initialAssets, + assets: args.assets, ); }, ); @@ -591,19 +608,16 @@ class CreateAlbumRoute extends PageRouteInfo { class CreateAlbumRouteArgs { const CreateAlbumRouteArgs({ this.key, - required this.isSharedAlbum, - this.initialAssets, + this.assets, }); final Key? key; - final bool isSharedAlbum; - - final List? initialAssets; + final List? assets; @override String toString() { - return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}'; + return 'CreateAlbumRouteArgs{key: $key, assets: $assets}'; } } @@ -755,6 +769,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 { @@ -857,6 +923,25 @@ class LibraryRoute extends PageRouteInfo { ); } +/// generated route for +/// [LocalAlbumsPage] +class LocalAlbumsRoute extends PageRouteInfo { + const LocalAlbumsRoute({List? children}) + : super( + LocalAlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'LocalAlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LocalAlbumsPage(); + }, + ); +} + /// generated route for /// [LoginPage] class LoginRoute extends PageRouteInfo { @@ -1059,6 +1144,25 @@ class PartnerRoute extends PageRouteInfo { ); } +/// generated route for +/// [PeopleCollectionPage] +class PeopleCollectionRoute extends PageRouteInfo { + const PeopleCollectionRoute({List? children}) + : super( + PeopleCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PeopleCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PeopleCollectionPage(); + }, + ); +} + /// generated route for /// [PermissionOnboardingPage] class PermissionOnboardingRoute extends PageRouteInfo { @@ -1149,6 +1253,25 @@ class PhotosRoute extends PageRouteInfo { ); } +/// generated route for +/// [PlacesCollectionPage] +class PlacesCollectionRoute extends PageRouteInfo { + const PlacesCollectionRoute({List? children}) + : super( + PlacesCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PlacesCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PlacesCollectionPage(); + }, + ); +} + /// generated route for /// [RecentlyAddedPage] class RecentlyAddedRoute extends PageRouteInfo { @@ -1169,29 +1292,29 @@ class RecentlyAddedRoute extends PageRouteInfo { } /// generated route for -/// [SearchInputPage] -class SearchInputRoute extends PageRouteInfo { - SearchInputRoute({ +/// [SearchPage] +class SearchRoute extends PageRouteInfo { + SearchRoute({ Key? key, SearchFilter? prefilter, List? children, }) : super( - SearchInputRoute.name, - args: SearchInputRouteArgs( + SearchRoute.name, + args: SearchRouteArgs( key: key, prefilter: prefilter, ), initialChildren: children, ); - static const String name = 'SearchInputRoute'; + static const String name = 'SearchRoute'; static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs( - orElse: () => const SearchInputRouteArgs()); - return SearchInputPage( + final args = + data.argsAs(orElse: () => const SearchRouteArgs()); + return SearchPage( key: args.key, prefilter: args.prefilter, ); @@ -1199,8 +1322,8 @@ class SearchInputRoute extends PageRouteInfo { ); } -class SearchInputRouteArgs { - const SearchInputRouteArgs({ +class SearchRouteArgs { + const SearchRouteArgs({ this.key, this.prefilter, }); @@ -1211,29 +1334,10 @@ class SearchInputRouteArgs { @override String toString() { - return 'SearchInputRouteArgs{key: $key, prefilter: $prefilter}'; + return 'SearchRouteArgs{key: $key, prefilter: $prefilter}'; } } -/// generated route for -/// [SearchPage] -class SearchRoute extends PageRouteInfo { - const SearchRoute({List? children}) - : super( - SearchRoute.name, - initialChildren: children, - ); - - static const String name = 'SearchRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const SearchPage(); - }, - ); -} - /// generated route for /// [SettingsPage] class SettingsRoute extends PageRouteInfo { @@ -1377,25 +1481,6 @@ class SharedLinkRoute extends PageRouteInfo { ); } -/// generated route for -/// [SharingPage] -class SharingRoute extends PageRouteInfo { - const SharingRoute({List? children}) - : super( - SharingRoute.name, - initialChildren: children, - ); - - static const String name = 'SharingRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const SharingPage(); - }, - ); -} - /// generated route for /// [SplashScreenPage] class SplashScreenRoute extends PageRouteInfo { diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index e16fecb32392a1..7d96b83d023f7d 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -1,12 +1,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.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'; @@ -21,35 +17,11 @@ class TabNavigationObserver extends AutoRouterObserver { required this.ref, }); - @override - void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) { - // Perform tasks on first navigation to SearchRoute - if (route.name == 'SearchRoute') { - // ref.refresh(getCuratedLocationProvider); - } - } - @override Future didChangeTabRoute( TabPageRoute route, TabPageRoute previousRoute, ) async { - // Perform tasks on re-visit to SearchRoute - if (route.name == 'SearchRoute') { - // Refresh Location State - ref.invalidate(getPreviewPlacesProvider); - ref.invalidate(getAllPeopleProvider); - } - - if (route.name == 'SharingRoute') { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - Future(() => ref.read(assetProvider.notifier).getAllAsset()); - } - - if (route.name == 'LibraryRoute') { - ref.read(albumProvider.notifier).getAllAlbums(); - } - if (route.name == 'HomeRoute') { ref.invalidate(memoryFutureProvider); Future(() => ref.read(assetProvider.notifier).getAllAsset()); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 091049edb59f1b..53a65e2869aea2 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -16,6 +16,7 @@ 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/models/albums/album_search.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'; @@ -152,7 +153,7 @@ class AlbumService { /// Checks remote albums (owned if `isShared` is false) for changes, /// updates the local database and returns `true` if there were any changes - Future refreshRemoteAlbums({required bool isShared}) async { + Future refreshRemoteAlbums() async { if (!_remoteCompleter.isCompleted) { // guard against concurrent calls return _remoteCompleter.future; @@ -162,12 +163,21 @@ class AlbumService { bool changes = false; try { await _userService.refreshUsers(); - final List serverAlbums = - await _albumApiRepository.getAll(shared: isShared ? true : null); - changes = await _syncService.syncRemoteAlbumsToDb( - serverAlbums, - isShared: isShared, + final List sharedAlbum = + await _albumApiRepository.getAll(shared: true); + + final List ownedAlbum = + await _albumApiRepository.getAll(shared: null); + + final albums = HashSet( + equals: (a, b) => a.remoteId == b.remoteId, + hashCode: (a) => a.remoteId.hashCode, ); + + albums.addAll(sharedAlbum); + albums.addAll(ownedAlbum); + + changes = await _syncService.syncRemoteAlbumsToDb(albums.toList()); } finally { _remoteCompleter.complete(changes); } @@ -213,9 +223,9 @@ class AlbumService { ); } - Future addAdditionalAssetToAlbum( - Iterable assets, + Future addAssets( Album album, + Iterable assets, ) async { try { final result = await _albumApiRepository.addAssets( @@ -234,7 +244,7 @@ class AlbumService { successfullyAdded: addedAssets.length, ); } catch (e) { - debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); + debugPrint("Error addAssets ${e.toString()}"); } return null; } @@ -253,30 +263,14 @@ class AlbumService { await _albumRepository.update(album); }); - Future addAdditionalUserToAlbum( - List sharedUserIds, - Album album, - ) async { - try { - 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()}"); - } - return false; - } - - Future setActivityEnabled(Album album, bool enabled) async { + Future setActivityStatus(Album album, bool enabled) async { try { final updatedAlbum = await _albumApiRepository.update( album.remoteId!, activityEnabled: enabled, ); - await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum); - await _albumRepository.update(updatedAlbum); + album.activityEnabled = updatedAlbum.activityEnabled; + await _albumRepository.update(album); return true; } catch (e) { debugPrint("Error setActivityEnabled ${e.toString()}"); @@ -327,7 +321,7 @@ class AlbumService { } } - Future removeAssetFromAlbum( + Future removeAsset( Album album, Iterable assets, ) async { @@ -346,7 +340,7 @@ class AlbumService { return false; } - Future removeUserFromAlbum( + Future removeUser( Album album, User user, ) async { @@ -363,22 +357,44 @@ class AlbumService { await _albumRepository.update(a!); return true; - } catch (e) { - debugPrint("Error removeUserFromAlbum ${e.toString()}"); + } catch (error) { + debugPrint("Error removeUser ${error.toString()}"); return false; } } + Future addUsers( + Album album, + List userIds, + ) async { + try { + final updatedAlbum = + await _albumApiRepository.addUsers(album.remoteId!, userIds); + + album.sharedUsers.addAll(updatedAlbum.remoteUsers); + album.shared = true; + + await _albumRepository.addUsers(album, album.sharedUsers.toList()); + await _albumRepository.update(album); + + return true; + } catch (error) { + debugPrint("Error addUsers ${error.toString()}"); + } + return false; + } + Future changeTitleAlbum( Album album, String newAlbumTitle, ) async { try { - album = await _albumApiRepository.update( + final updatedAlbum = await _albumApiRepository.update( album.remoteId!, name: newAlbumTitle, ); - await _entityService.fillAlbumWithDatabaseEntities(album); + + album.name = updatedAlbum.name; await _albumRepository.update(album); return true; } catch (e) { @@ -405,4 +421,15 @@ class AlbumService { } } } + + Future> getAll() async { + return _albumRepository.getAll(remote: true); + } + + Future> search( + String searchTerm, + QuickFilterMode filterMode, + ) async { + return _albumRepository.search(searchTerm, filterMode); + } } diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 4a3cfb19a2a28d..515023d163f1ce 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/entity.service.dart b/mobile/lib/services/entity.service.dart index 8297620bc70e37..ddbe77f8c9493c 100644 --- a/mobile/lib/services/entity.service.dart +++ b/mobile/lib/services/entity.service.dart @@ -32,6 +32,7 @@ class EntityService { .getByIds(album.remoteUsers.map((user) => user.id).toList()); album.sharedUsers.clear(); album.sharedUsers.addAll(users); + album.shared = true; } if (album.remoteAssets.isNotEmpty) { // replace all assets with assets from database diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index 658bffc44fd0f6..f1a6e9b0d7365d 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -95,10 +95,9 @@ class SyncService { /// Syncs remote albums to the database /// returns `true` if there were any changes Future syncRemoteAlbumsToDb( - List remote, { - required bool isShared, - }) => - _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared)); + List remote, + ) => + _lock.run(() => _syncRemoteAlbumsToDb(remote)); /// Syncs all device albums and their assets to the database /// Returns `true` if there were any changes @@ -138,7 +137,6 @@ class SyncService { Future _syncUsersFromServer(List users) async { users.sortBy((u) => u.id); final dbUsers = await _userRepository.getAll(sortBy: UserSort.id); - assert(dbUsers.isSortedBy((u) => u.id), "dbUsers not sorted!"); final List toDelete = []; final List toUpsert = []; final changes = diffSortedListsSync( @@ -311,18 +309,13 @@ class SyncService { /// returns `true` if there were any changes Future _syncRemoteAlbumsToDb( List remoteAlbums, - bool isShared, ) async { 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, ); - assert(dbAlbums.isSortedBy((e) => e.remoteId!), "dbAlbums not sorted!"); final List toDelete = []; final List existing = []; @@ -338,7 +331,7 @@ class SyncService { onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete), ); - if (isShared && toDelete.isNotEmpty) { + if (toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { await _assetRepository.deleteById(idsToRemove); @@ -512,7 +505,6 @@ class SyncService { 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, @@ -805,8 +797,7 @@ class SyncService { assets.sort(Asset.compareByOwnerChecksumCreatedModified); assets.uniqueConsecutive( compare: Asset.compareByOwnerChecksum, - onDuplicate: (a, b) => - _log.info("Ignoring duplicate assets on device:\n$a\n$b"), + onDuplicate: (a, b) => {}, ); final int duplicates = before - assets.length; if (duplicates > 0) { diff --git a/mobile/lib/utils/diff.dart b/mobile/lib/utils/diff.dart index 18e3843819030a..a36902d8c7952b 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/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 0aac5b476efda0..c0cf60514f04d6 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -190,17 +190,14 @@ ThemeData getThemeData({required ColorScheme colorScheme}) { displayLarge: TextStyle( fontSize: 26, fontWeight: FontWeight.bold, - color: isDark ? Colors.white : primaryColor, ), displayMedium: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, - color: isDark ? Colors.white : Colors.black87, ), displaySmall: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, - color: primaryColor, ), titleSmall: const TextStyle( fontSize: 16.0, @@ -241,7 +238,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) { isDark ? colorScheme.surfaceContainer : colorScheme.surface, labelTextStyle: const WidgetStatePropertyAll( TextStyle( - fontSize: 13, + fontSize: 14, fontWeight: FontWeight.w500, ), ), diff --git a/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart b/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart index 46fa0b1fe8ac1c..6856ae184d038d 100644 --- a/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart +++ b/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart @@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -27,13 +26,11 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final albumService = ref.watch(albumServiceProvider); - final sharedAlbums = ref.watch(sharedAlbumProvider); useEffect( () { // Fetch album updates, e.g., cover image - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + ref.read(albumProvider.notifier).refreshRemoteAlbums(); return null; }, @@ -41,9 +38,9 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { ); void addToAlbum(Album album) async { - final result = await albumService.addAdditionalAssetToAlbum( - assets, + final result = await albumService.addAssets( album, + assets, ); if (result != null) { @@ -107,8 +104,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { onPressed: () { context.pushRoute( CreateAlbumRoute( - isSharedAlbum: false, - initialAssets: assets, + assets: assets, ), ); }, @@ -123,7 +119,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16), sliver: AddToAlbumSliverList( albums: albums, - sharedAlbums: sharedAlbums, + sharedAlbums: albums.where((a) => a.shared).toList(), onAddToAlbum: addToAlbum, ), ), diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index 42fa55cdd44599..b728f2b5415fe0 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -12,12 +12,14 @@ class AlbumThumbnailCard extends StatelessWidget { /// Whether or not to show the owner of the album (or "Owned") /// in the subtitle of the album final bool showOwner; + final bool showTitle; const AlbumThumbnailCard({ super.key, required this.album, this.onTap, this.showOwner = false, + this.showTitle = true, }); final Album album; @@ -76,7 +78,7 @@ class AlbumThumbnailCard extends StatelessWidget { : 'album_thumbnail_card_items' .tr(args: ['${album.assetCount}']), ), - if (owner != null) const TextSpan(text: ' · '), + if (owner != null) const TextSpan(text: ' • '), if (owner != null) TextSpan(text: owner), ], ), @@ -102,21 +104,23 @@ class AlbumThumbnailCard extends StatelessWidget { : buildAlbumThumbnail(), ), ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: SizedBox( - width: cardSize, - child: Text( - album.name, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, + if (showTitle) ...[ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: cardSize, + child: Text( + album.name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), ), ), ), - ), - buildAlbumTextRow(), + buildAlbumTextRow(), + ], ], ), ), diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 1067d7241e3e4e..89528cc4da3654 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_viewer.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -46,10 +45,8 @@ class AlbumViewerAppbar extends HookConsumerWidget final bool success; if (album.shared) { - success = - await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + success = await ref.watch(albumProvider.notifier).deleteAlbum(album); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } else { success = await ref.watch(albumProvider.notifier).deleteAlbum(album); context @@ -113,11 +110,10 @@ class AlbumViewerAppbar extends HookConsumerWidget isProcessing.value = true; bool isSuccess = - await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album); + await ref.watch(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } else { context.pop(); ImmichToast.show( diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart index e6d769a3d7aa21..ec054d08ee1311 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -72,7 +71,8 @@ class ControlBottomAppBar extends HookConsumerWidget { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); - final sharedAlbums = ref.watch(sharedAlbumProvider); + final sharedAlbums = + ref.watch(albumProvider).where((a) => a.shared).toList(); const bottomPadding = 0.20; final scrollController = useDraggableScrollController(); diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 14678903ba298b..eeecfa9b584358 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -9,7 +9,6 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -272,11 +271,10 @@ class MultiselectGrid extends HookConsumerWidget { if (assets.isEmpty) { return; } - final result = - await ref.read(albumServiceProvider).addAdditionalAssetToAlbum( - assets, - album, - ); + final result = await ref.read(albumServiceProvider).addAssets( + album, + assets, + ); if (result != null) { if (result.alreadyInAlbum.isNotEmpty) { @@ -323,8 +321,7 @@ class MultiselectGrid extends HookConsumerWidget { .createAlbumWithGeneratedName(assets); if (result != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); + ref.watch(albumProvider.notifier).refreshRemoteAlbums(); selectionEnabledHook.value = false; context.pushRoute(AlbumViewerRoute(albumId: result.id)); diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index c3f1390dba04a5..f550857b9d8679 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -6,8 +6,8 @@ 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/providers/album/album.provider.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/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; @@ -230,9 +230,7 @@ class BottomGalleryBar extends ConsumerWidget { handleRemoveFromAlbum() async { final album = ref.read(currentAlbumProvider); final bool isSuccess = album != null && - await ref - .read(sharedAlbumProvider.notifier) - .removeAssetFromAlbum(album, [asset]); + await ref.read(albumProvider.notifier).removeAsset(album, [asset]); if (isSuccess) { // Workaround for asset remaining in the gallery diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 8e2465fc9ca3d4..1831a2d1689ab2 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -18,9 +18,10 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); - final Widget? action; + final List? actions; + final bool showUploadButton; - const ImmichAppBar({super.key, this.action}); + const ImmichAppBar({super.key, this.actions, this.showUploadButton = true}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -184,12 +185,18 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { }, ), actions: [ - if (action != null) - Padding(padding: const EdgeInsets.only(right: 20), child: action!), - Padding( - padding: const EdgeInsets.only(right: 20), - child: buildBackupIndicator(), - ), + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, + ), + ), + if (showUploadButton) + Padding( + padding: const EdgeInsets.only(right: 20), + child: buildBackupIndicator(), + ), Padding( padding: const EdgeInsets.only(right: 20), child: buildProfileIndicator(), diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 01b717ef5b9775..46e86718583df1 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { usernameController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://192.168.1.16:2283/api'; + serverEndpointController.text = 'http://192.168.1.118:2283/api'; } login() async { diff --git a/mobile/lib/widgets/partner/partner_list.dart b/mobile/lib/widgets/partner/partner_list.dart deleted file mode 100644 index 53a27c48abad7f..00000000000000 --- a/mobile/lib/widgets/partner/partner_list.dart +++ /dev/null @@ -1,48 +0,0 @@ -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/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/widgets/common/user_avatar.dart'; - -class PartnerList extends HookConsumerWidget { - const PartnerList({super.key, required this.partner}); - - final List partner; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return SliverList( - delegate: - SliverChildBuilderDelegate(listEntry, childCount: partner.length), - ); - } - - Widget listEntry(BuildContext context, int index) { - final User p = partner[index]; - return ListTile( - contentPadding: const EdgeInsets.only( - left: 12.0, - right: 18.0, - ), - leading: userAvatar(context, p, radius: 24), - title: Text( - "partner_list_user_photos", - style: context.textTheme.labelLarge, - ).tr( - namedArgs: { - 'user': p.name, - }, - ), - trailing: Text( - "partner_list_view_all", - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - onTap: () => context.pushRoute((PartnerDetailRoute(partner: p))), - ); - } -} diff --git a/mobile/lib/widgets/search/explore_grid.dart b/mobile/lib/widgets/search/explore_grid.dart index 8e90cc85048636..cd937a6a42d4b7 100644 --- a/mobile/lib/widgets/search/explore_grid.dart +++ b/mobile/lib/widgets/search/explore_grid.dart @@ -59,7 +59,7 @@ class ExploreGrid extends StatelessWidget { ), ) : context.pushRoute( - SearchInputRoute( + SearchRoute( prefilter: SearchFilter( people: {}, location: SearchLocationFilter( diff --git a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart index 7db2eea70b490c..2a445c8ad7ced6 100644 --- a/mobile/lib/widgets/search/search_filter/search_filter_chip.dart +++ b/mobile/lib/widgets/search/search_filter/search_filter_chip.dart @@ -48,7 +48,7 @@ class SearchFilterChip extends StatelessWidget { child: Card( elevation: 0, shape: StadiumBorder( - side: BorderSide(color: context.colorScheme.outline.withOpacity(.5)), + side: BorderSide(color: context.colorScheme.outline.withAlpha(15)), ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 14.0), diff --git a/mobile/lib/widgets/search/search_map_thumbnail.dart b/mobile/lib/widgets/search/search_map_thumbnail.dart index 20747913fb14a8..b4a12ab82634be 100644 --- a/mobile/lib/widgets/search/search_map_thumbnail.dart +++ b/mobile/lib/widgets/search/search_map_thumbnail.dart @@ -13,6 +13,7 @@ class SearchMapThumbnail extends StatelessWidget { }); final double size; + final bool showTitle = true; @override Widget build(BuildContext context) { diff --git a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart index c093e8f1e3c98f..2cecba6c4bdd24 100644 --- a/mobile/lib/widgets/settings/backup_settings/backup_settings.dart +++ b/mobile/lib/widgets/settings/backup_settings/backup_settings.dart @@ -48,9 +48,8 @@ class BackupSettings extends HookConsumerWidget { if (Platform.isIOS) SettingsSwitchListTile( valueNotifier: ignoreIcloudAssets, - title: 'Ignore iCloud photos', - subtitle: - 'Photos that are stored on iCloud will not be uploaded to the Immich server', + title: 'ignore_icloud_photos'.tr(), + subtitle: 'ignore_icloud_photos_description'.tr(), ), if (Platform.isAndroid && isAdvancedTroubleshooting.value) SettingsButtonListTile( diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 36f442fd88b513..3bd0afd5f5497e 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.116.2 +- API version: 1.118.1 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen @@ -115,7 +115,6 @@ 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 | @@ -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 | diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart index bc8f50092a030a..30e35b451cfc3a 100644 --- a/mobile/openapi/lib/api/deprecated_api.dart +++ b/mobile/openapi/lib/api/deprecated_api.dart @@ -16,62 +16,6 @@ class DeprecatedApi { final ApiClient apiClient; - /// 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; - } - /// This property was deprecated in v1.116.0 /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 95c4a2fd45cf41..7df0d66c79cfb9 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/model/asset_job_name.dart b/mobile/openapi/lib/model/asset_job_name.dart index a5b42f4ee52ebf..11e0555b868d49 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/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index 649e0128a7a97b..32274037f6d5b0 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; } @@ -52,7 +62,7 @@ class JobCommandDto { return JobCommandDto( command: JobCommand.fromJson(json[r'command'])!, - force: mapValueOfType(json, r'force')!, + force: mapValueOfType(json, r'force'), ); } return null; @@ -101,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/pubspec.lock b/mobile/pubspec.lock index 9a840ee2d436ca..1f29bf7830c6d3 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -66,10 +66,10 @@ packages: dependency: "direct main" description: name: auto_route - sha256: bb673104dbdc22667d01ec668df3d2a358b6e3da481428eeb1151933cfc1a7d6 + sha256: b83e8ce46da7228cdd019b5a11205454847f0a971bca59a7529b98df9876889b url: "https://pub.dev" source: hosted - version: "9.2.0" + version: "9.2.2" auto_route_generator: dependency: "direct dev" description: @@ -266,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: @@ -378,10 +378,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" + sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091 url: "https://pub.dev" source: hosted - version: "9.1.2" + version: "11.0.0" device_info_plus_platform_interface: dependency: transitive description: @@ -450,10 +450,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "825aec673606875c33cd8d3c4083f1a3c3999015a84178b317b7ef396b7384f3" + sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" url: "https://pub.dev" source: hosted - version: "8.0.7" + version: "8.1.2" file_selector_linux: dependency: transitive description: @@ -635,10 +635,10 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: "7eae679e596a44fdf761853a706f74979f8dd3cd92cf4e23cae161fda091b847" + sha256: "95f349437aeebe524ef7d6c9bde3e6b4772717cf46a0eb6a3ceaddc740b297cc" url: "https://pub.dev" source: hosted - version: "8.2.6" + version: "8.2.8" freezed_annotation: dependency: transitive description: @@ -1067,10 +1067,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "4de6c36df77ffbcef0a5aefe04669d33f2d18397fea228277b852a2d4e58e860" + sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef" url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.0.3" package_info_plus_platform_interface: dependency: transitive description: @@ -1179,10 +1179,10 @@ packages: dependency: transitive description: name: permission_handler_html - sha256: "6cac773d389e045a8d4f85418d07ad58ef9e42a56e063629ce14c4c26344de24" + sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851 url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.3+2" permission_handler_platform_interface: dependency: transitive description: @@ -1339,18 +1339,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "59dfd53f497340a0c3a81909b220cfdb9b8973a91055c4e5ab9b9b9ad7c513c0" + sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11 url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.3" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5" + sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.0.1" shared_preferences: dependency: transitive description: @@ -1760,10 +1760,10 @@ packages: dependency: "direct main" description: name: wakelock_plus - sha256: "4fa83a128b4127619e385f686b4f080a5d2de46cff8e8c94eccac5fcf76550e5" + sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484 url: "https://pub.dev" source: hosted - version: "1.2.7" + version: "1.2.8" wakelock_plus_platform_interface: dependency: transitive description: @@ -1784,10 +1784,10 @@ packages: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.0" web_socket: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 05328507af94df..e5fe53d3581d57 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.116.2+161 +version: 1.118.1+163 environment: sdk: '>=3.3.0 <4.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: ^11.0.0 + connectivity_plus: ^6.0.0 wakelock_plus: ^1.1.4 flutter_local_notifications: ^17.2.1+2 timezone: ^0.9.2 diff --git a/mobile/scripts/check_i18n_keys.py b/mobile/scripts/check_i18n_keys.py index 8d748ceb06e4a6..c3b53dc5a68183 100644 --- a/mobile/scripts/check_i18n_keys.py +++ b/mobile/scripts/check_i18n_keys.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 import json import subprocess - def main(): - with open('assets/i18n/en-US.json', 'r') as f: + with open('assets/i18n/en-US.json', 'r+') as f: data = json.load(f) + keys_to_delete = [] for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="*.dart" "{k}"']) + sp = subprocess.run(['sh', '-c', f'grep -q -r --include="*.dart" "{k}"']) if sp.returncode != 0: - print("Not found in source code!") - return 1 + print("Not found in source code, key:", k) + keys_to_delete.append(k) + + for k in keys_to_delete: + del data[k] + + f.seek(0) + f.truncate() + json.dump(data, f, indent=4) if __name__ == '__main__': main() \ No newline at end of file diff --git a/mobile/scripts/check_key_uniform.py b/mobile/scripts/check_key_uniform.py deleted file mode 100644 index 970f491f365ab6..00000000000000 --- a/mobile/scripts/check_key_uniform.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -import json -import subprocess - -def main(): - print("CHECK GERMAN TRANSLATIONS") - with open('assets/i18n/de-DE.json', 'r') as f: - data = json.load(f) - - for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="./assets/i18n/en-US.json" "{k}"']) - - if sp.returncode != 0: - print(f"Outdated Key! {k}") - return 1 - - print("CHECK FRENCH TRANSLATIONS") - with open('assets/i18n/fr-FR.json', 'r') as f: - data = json.load(f) - - for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="./assets/i18n/en-US.json" "{k}"']) - - if sp.returncode != 0: - print(f"Outdated Key! {k}") - return 1 - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index fb46dceed592fa..848d7cfad70781 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -79,25 +79,35 @@ void main() { verifyNoMoreInteractions(syncService); }); }); + group('refreshRemoteAlbums', () { - test('isShared: false', () async { + test('is working', () async { when(() => userService.refreshUsers()).thenAnswer((_) async => true); + when(() => albumApiRepository.getAll(shared: true)) + .thenAnswer((_) async => [AlbumStub.sharedWithUser]); + when(() => albumApiRepository.getAll(shared: null)) .thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]); + when( - () => syncService.syncRemoteAlbumsToDb( - [AlbumStub.oneAsset, AlbumStub.twoAsset], - isShared: false, - ), + () => syncService.syncRemoteAlbumsToDb([ + AlbumStub.twoAsset, + AlbumStub.oneAsset, + AlbumStub.sharedWithUser, + ]), ).thenAnswer((_) async => true); - final result = await sut.refreshRemoteAlbums(isShared: false); + final result = await sut.refreshRemoteAlbums(); expect(result, true); verify(() => userService.refreshUsers()).called(1); + verify(() => albumApiRepository.getAll(shared: true)).called(1); verify(() => albumApiRepository.getAll(shared: null)).called(1); verify( () => syncService.syncRemoteAlbumsToDb( - [AlbumStub.oneAsset, AlbumStub.twoAsset], - isShared: false, + [ + AlbumStub.twoAsset, + AlbumStub.oneAsset, + AlbumStub.sharedWithUser, + ], ), ).called(1); verifyNoMoreInteractions(userService); @@ -166,9 +176,9 @@ void main() { () => albumRepository.update(AlbumStub.oneAsset), ).thenAnswer((_) async => AlbumStub.oneAsset); - final result = await sut.addAdditionalAssetToAlbum( - [AssetStub.image1, AssetStub.image2], + final result = await sut.addAssets( AlbumStub.oneAsset, + [AssetStub.image1, AssetStub.image2], ); expect(result != null, true); @@ -185,18 +195,23 @@ void main() { ).thenAnswer( (_) async => AlbumStub.sharedWithUser, ); + when( - () => entityService - .fillAlbumWithDatabaseEntities(AlbumStub.sharedWithUser), - ).thenAnswer((_) async => AlbumStub.sharedWithUser); + () => albumRepository.addUsers( + AlbumStub.emptyAlbum, + AlbumStub.emptyAlbum.sharedUsers.toList(), + ), + ).thenAnswer((_) async => AlbumStub.emptyAlbum); + when( - () => albumRepository.update(AlbumStub.sharedWithUser), - ).thenAnswer((_) async => AlbumStub.sharedWithUser); + () => albumRepository.update(AlbumStub.emptyAlbum), + ).thenAnswer((_) async => AlbumStub.emptyAlbum); - final result = await sut.addAdditionalUserToAlbum( - [UserStub.user2.id], + final result = await sut.addUsers( AlbumStub.emptyAlbum, + [UserStub.user2.id], ); + expect(result, true); }); }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d28effd6c56766..8f8449c7319b9e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4030,57 +4030,6 @@ ] } }, - "/people/{id}/assets": { - "get": { - "deprecated": true, - "description": "This property was deprecated in v1.113.0", - "operationId": "getPersonAssets", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "People", - "Deprecated" - ], - "x-immich-lifecycle": { - "deprecatedAt": "v1.113.0" - } - } - }, "/people/{id}/merge": { "post": { "operationId": "mergePerson", @@ -7436,7 +7385,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.2", + "version": "1.118.1", "contact": {} }, "tags": [], @@ -8215,8 +8164,9 @@ }, "AssetJobName": { "enum": [ - "regenerate-thumbnail", + "refresh-faces", "refresh-metadata", + "regenerate-thumbnail", "transcode-video" ], "type": "string" @@ -9277,8 +9227,7 @@ } }, "required": [ - "command", - "force" + "command" ], "type": "object" }, diff --git a/open-api/typescript-sdk/.nvmrc b/open-api/typescript-sdk/.nvmrc index 3516580bbbc04b..2a393af592b8cd 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 e977f56834fb30..db51101bdc94eb 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.116.2", + "version": "1.118.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.2", + "version": "1.118.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.9", + "@types/node": "^20.16.11", "typescript": "^5.3.3" } }, @@ -22,9 +22,9 @@ "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" }, "node_modules/@types/node": { - "version": "20.16.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", - "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", + "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": { diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 17472327f75dd7..a4ba46ae42f1ca 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.2", + "version": "1.118.1", "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.9", + "@types/node": "^20.16.11", "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 4f5eed0d13e21c..ec0fce70032e71 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.116.2 + * 1.118.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -554,7 +554,7 @@ export type JobCreateDto = { }; export type JobCommandDto = { command: JobCommand; - force: boolean; + force?: boolean; }; export type LibraryResponseDto = { assetCount: number; @@ -2384,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; @@ -3426,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 { diff --git a/renovate.json b/renovate.json index ccfb75b19c1577..39e0e7f811f029 100644 --- a/renovate.json +++ b/renovate.json @@ -15,7 +15,7 @@ "groupName": "typescript-projects", "matchUpdateTypes": ["minor", "patch"], "excludePackagePrefixes": ["exiftool", "reflect-metadata"], - "excludePackageNames": ["node", "@types/node"], + "excludePackageNames": ["node", "@types/node", "@mapbox/mapbox-gl-rtl-text"], "schedule": "on tuesday" }, { diff --git a/server/.nvmrc b/server/.nvmrc index 3516580bbbc04b..2a393af592b8cd 100644 --- a/server/.nvmrc +++ b/server/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/server/Dockerfile b/server/Dockerfile index 4ebae191e914df..0c8360611f512e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20241001@sha256:bb10832c2567f5625df68bb790523e85a358031ddcb3d7ac98b669f62ed8de27 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:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 AS web +FROM node:20.18.0-alpine3.20@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9 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:20241001@sha256:a9a0745a486e9cbd73fa06b49168e985f8f2c1be0fca9fb0a8e06916246c7087 +FROM ghcr.io/immich-app/base-server-prod:20241008@sha256:c0cf2a16987a53d9c2f00f127415da537b5812055a6855a62e4b0abd33c4d695 WORKDIR /usr/src/app ENV NODE_ENV=production \ @@ -76,7 +76,7 @@ ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} VOLUME /usr/src/app/upload -EXPOSE 3001 +EXPOSE 2283 ENTRYPOINT ["tini", "--", "/bin/bash"] CMD ["start.sh"] diff --git a/server/bin/immich-healthcheck b/server/bin/immich-healthcheck index 6043e526aa9199..cf0accb8ddb32c 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 d5138724daf45f..7e91e4f07c5fbf 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.2", + "version": "1.118.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.2", + "version": "1.118.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", @@ -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.9", + "@types/node": "^20.16.11", "@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", @@ -5076,12 +5078,12 @@ } }, "node_modules/@testcontainers/postgresql": { - "version": "10.13.1", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.13.1.tgz", - "integrity": "sha512-HAh/3uLAzAhOmzXsOE6hVxkvetczPnX/Zoyt+SgK7QotW98Npr1MDx8OKiaLGTJ8XkIvVvS4Ch6bl+frt4pnkQ==", + "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.13.1" + "testcontainers": "^10.13.2" } }, "node_modules/@tsconfig/node10": { @@ -5341,9 +5343,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.17.9", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz", - "integrity": "sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==", + "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": { @@ -5398,9 +5400,9 @@ } }, "node_modules/@types/node": { - "version": "20.16.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", - "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", + "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" } @@ -5496,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", @@ -5515,9 +5527,9 @@ "dev": true }, "node_modules/@types/react": { - "version": "18.3.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.9.tgz", - "integrity": "sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ==", + "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": "*", @@ -5634,16 +5646,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz", - "integrity": "sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==", + "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.7.0", - "@typescript-eslint/type-utils": "8.7.0", - "@typescript-eslint/utils": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -5667,15 +5679,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.7.0.tgz", - "integrity": "sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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": { @@ -5695,13 +5707,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz", - "integrity": "sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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" @@ -5712,13 +5724,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz", - "integrity": "sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==", + "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.7.0", - "@typescript-eslint/utils": "8.7.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -5736,9 +5748,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.7.0.tgz", - "integrity": "sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==", + "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" @@ -5749,13 +5761,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz", - "integrity": "sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -5801,15 +5813,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.7.0.tgz", - "integrity": "sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.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" @@ -5823,12 +5835,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz", - "integrity": "sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==", + "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.7.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -5840,9 +5852,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", - "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", + "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", @@ -5862,8 +5874,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.1", - "vitest": "2.1.1" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5881,13 +5893,13 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", - "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", + "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.1.1", - "@vitest/utils": "2.1.1", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -5896,9 +5908,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", - "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "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", @@ -5909,7 +5921,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.1", + "@vitest/spy": "2.1.2", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -5932,9 +5944,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", - "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", + "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" @@ -5944,12 +5956,12 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", - "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", + "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.1.1", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -5957,12 +5969,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", - "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -5980,9 +5992,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", - "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", + "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" @@ -5992,12 +6004,12 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", - "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -8515,9 +8527,10 @@ } }, "node_modules/exiftool-vendored": { - "version": "28.3.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.3.0.tgz", - "integrity": "sha512-2DOSOvj5c1gkbKtubAnlGglxdYp9h55n0GxjK2nypVivoaCdgP/le3MOZRKgEUNObfJHmYHj4u/NnYVneu/gUw==", + "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": "^11.0.0", "@types/luxon": "^3.4.2", @@ -9095,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", @@ -9225,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" }, @@ -10187,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", @@ -11458,6 +11458,16 @@ "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", @@ -14028,9 +14038,9 @@ } }, "node_modules/testcontainers": { - "version": "10.13.1", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.1.tgz", - "integrity": "sha512-JBbOhxmygj/ouH/47GnoVNt+c55Telh/45IjVxEbDoswsLchVmJiuKiw/eF6lE5i7LN+/99xsrSCttI3YRtirg==", + "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", @@ -14875,9 +14885,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", - "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", + "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", @@ -14915,18 +14925,18 @@ } }, "node_modules/vitest": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", - "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, "dependencies": { - "@vitest/expect": "2.1.1", - "@vitest/mocker": "2.1.1", - "@vitest/pretty-format": "^2.1.1", - "@vitest/runner": "2.1.1", - "@vitest/snapshot": "2.1.1", - "@vitest/spy": "2.1.1", - "@vitest/utils": "2.1.1", + "@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.6", "magic-string": "^0.30.11", @@ -14937,7 +14947,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.1", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -14952,8 +14962,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.1", - "@vitest/ui": "2.1.1", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, @@ -18408,12 +18418,12 @@ } }, "@testcontainers/postgresql": { - "version": "10.13.1", - "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.13.1.tgz", - "integrity": "sha512-HAh/3uLAzAhOmzXsOE6hVxkvetczPnX/Zoyt+SgK7QotW98Npr1MDx8OKiaLGTJ8XkIvVvS4Ch6bl+frt4pnkQ==", + "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.13.1" + "testcontainers": "^10.13.2" } }, "@tsconfig/node10": { @@ -18664,9 +18674,9 @@ "dev": true }, "@types/lodash": { - "version": "4.17.9", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz", - "integrity": "sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==", + "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": { @@ -18721,9 +18731,9 @@ } }, "@types/node": { - "version": "20.16.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz", - "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==", + "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" } @@ -18806,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", @@ -18825,9 +18844,9 @@ "dev": true }, "@types/react": { - "version": "18.3.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.9.tgz", - "integrity": "sha512-+BpAVyTpJkNWWSSnaLBk6ePpHLOGJKnEQNbINNovPWzvEUyAe3e+/d494QdEh71RekM/qV7lw6jzf1HGrJyAtQ==", + "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": "*", @@ -18944,16 +18963,16 @@ "integrity": "sha512-c/hzNDBh7eRF+KbCf+OoZxKbnkpaK/cKp9iLQWqB7muXtM+MtL9SUUH8vCFcLn6dH1Qm05jiexK0ofWY7TfOhQ==" }, "@typescript-eslint/eslint-plugin": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz", - "integrity": "sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==", + "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.7.0", - "@typescript-eslint/type-utils": "8.7.0", - "@typescript-eslint/utils": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -18961,54 +18980,54 @@ } }, "@typescript-eslint/parser": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.7.0.tgz", - "integrity": "sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz", - "integrity": "sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.0" + "@typescript-eslint/types": "8.8.0", + "@typescript-eslint/visitor-keys": "8.8.0" } }, "@typescript-eslint/type-utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz", - "integrity": "sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==", + "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.7.0", - "@typescript-eslint/utils": "8.7.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.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.7.0.tgz", - "integrity": "sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==", + "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.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz", - "integrity": "sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -19038,31 +19057,31 @@ } }, "@typescript-eslint/utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.7.0.tgz", - "integrity": "sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.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.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz", - "integrity": "sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==", + "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.7.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" } }, "@vitest/coverage-v8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", - "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", + "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", @@ -19091,21 +19110,21 @@ } }, "@vitest/expect": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", - "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", + "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.1.1", - "@vitest/utils": "2.1.1", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" } }, "@vitest/mocker": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", - "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "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", @@ -19125,31 +19144,31 @@ } }, "@vitest/pretty-format": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", - "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", + "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.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", - "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", + "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.1.1", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" } }, "@vitest/snapshot": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", - "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -19166,21 +19185,21 @@ } }, "@vitest/spy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", - "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", + "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.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", - "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" } @@ -21033,9 +21052,9 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, "exiftool-vendored": { - "version": "28.3.0", - "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.3.0.tgz", - "integrity": "sha512-2DOSOvj5c1gkbKtubAnlGglxdYp9h55n0GxjK2nypVivoaCdgP/le3MOZRKgEUNObfJHmYHj4u/NnYVneu/gUw==", + "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": "^11.0.0", "@types/luxon": "^3.4.2", @@ -21503,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", @@ -21588,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": { @@ -22293,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", @@ -23214,6 +23224,12 @@ "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", @@ -24966,9 +24982,9 @@ } }, "testcontainers": { - "version": "10.13.1", - "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.13.1.tgz", - "integrity": "sha512-JBbOhxmygj/ouH/47GnoVNt+c55Telh/45IjVxEbDoswsLchVmJiuKiw/eF6lE5i7LN+/99xsrSCttI3YRtirg==", + "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", @@ -25473,9 +25489,9 @@ } }, "vite-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", - "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", + "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", @@ -25496,18 +25512,18 @@ } }, "vitest": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", - "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.2.tgz", + "integrity": "sha512-veNjLizOMkRrJ6xxb+pvxN6/QAWg95mzcRjtmkepXdN87FNfxAss9RKe2far/G9cQpipfgP2taqg0KiWsquj8A==", "dev": true, "requires": { - "@vitest/expect": "2.1.1", - "@vitest/mocker": "2.1.1", - "@vitest/pretty-format": "^2.1.1", - "@vitest/runner": "2.1.1", - "@vitest/snapshot": "2.1.1", - "@vitest/spy": "2.1.1", - "@vitest/utils": "2.1.1", + "@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.6", "magic-string": "^0.30.11", @@ -25518,7 +25534,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.1", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "dependencies": { diff --git a/server/package.json b/server/package.json index 2e6238ad54d6cc..bf39d938ab14e0 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.2", + "version": "1.118.1", "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", @@ -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.9", + "@types/node": "^20.16.11", "@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 55b9babcb476b9..3f1e2ba08da077 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -4,7 +4,6 @@ import { ConfigModule } from '@nestjs/config'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE, ModuleRef } from '@nestjs/core'; 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'; @@ -12,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'; @@ -22,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]; @@ -56,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; } } 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 763fce81b4fc05..6de58c2002fef1 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/config.ts b/server/src/config.ts index d207d6763cda97..4fdf23ecc2cb6d 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -408,45 +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, - 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, -}); - -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 c62c06ffa2674f..5317d5e13c57a0 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,35 +19,12 @@ 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 APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; 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'; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index ab569d743425af..f10bf601b4e56e 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/person.controller.ts b/server/src/controllers/person.controller.ts index 5462305d9f94e2..ba9a181c410159 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/server-info.controller.ts b/server/src/controllers/server-info.controller.ts deleted file mode 100644 index 36490b71198baf..00000000000000 --- 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.getSystemConfig(); - } - - @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/cores/storage.core.ts b/server/src/cores/storage.core.ts index 8e42cd10764de1..c49175172d66e7 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -15,9 +15,6 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { getAssetFiles } from 'src/utils/asset.util'; 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)); - export interface MoveRequest { entityId: string; pathType: PathType; @@ -118,10 +115,6 @@ export class StorageCore { return normalizedPath.startsWith(normalizedAppMediaLocation); } - static isGeneratedAsset(path: string) { - return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR); - } - async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) { const { id: entityId, files } = asset; const { thumbnailFile, previewFile } = getAssetFiles(files); diff --git a/server/src/database.config.ts b/server/src/database.config.ts index 9cc317a7341608..2a46067bc1ce10 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/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 703b1ccfe3225b..42d6d7d7451ebe 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -92,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/job.dto.ts b/server/src/dtos/job.dto.ts index 895f710b7a7820..49e4cfb67b3c82 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -18,7 +18,7 @@ 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 { diff --git a/server/src/enum.ts b/server/src/enum.ts index d1a76573d160bf..109e9a90b7ebac 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -334,3 +334,8 @@ export enum ImmichEnvironment { TESTING = 'testing', PRODUCTION = 'production', } + +export enum ImmichWorker { + API = 'api', + MICROSERVICES = 'microservices', +} diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 750a85209474c0..37d3326a8abc54 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -172,12 +172,6 @@ export interface IAssetRepository { order?: FindOptionsOrder, ): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getWith( - pagination: PaginationOptions, - property: WithProperty, - libraryId?: string, - withDeleted?: boolean, - ): Paginated; getRandom(userIds: string[], count: number): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; diff --git a/server/src/interfaces/config.interface.ts b/server/src/interfaces/config.interface.ts index 9d785e316ad2e6..d105e40cf90d5d 100644 --- a/server/src/interfaces/config.interface.ts +++ b/server/src/interfaces/config.interface.ts @@ -1,22 +1,68 @@ -import { ImmichEnvironment, LogLevel } from 'src/enum'; +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; } diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index e388f354f2ac1b..79550d416ea5e5 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -48,7 +48,6 @@ export interface IDatabaseRepository { getPostgresVersion(): Promise; getPostgresVersionRange(): string; createExtension(extension: DatabaseExtension): Promise; - updateExtension(extension: DatabaseExtension, version?: string): Promise; updateVectorExtension(extension: VectorExtension, version?: string): Promise; reindex(index: VectorIndex): Promise; shouldReindex(name: VectorIndex): Promise; diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index a125e47ada3b4c..7ea48faf5380f5 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -1,13 +1,15 @@ +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 EventMap = { // app events - 'app.bootstrap': ['api' | 'microservices']; - 'app.shutdown': []; + 'app.bootstrap': [ImmichWorker]; + 'app.shutdown': [ImmichWorker]; // config events 'config.update': [ @@ -85,6 +87,7 @@ export type EventItem = { }; export interface IEventRepository { + setup(options: { services: ClassConstructor[] }): void; on(item: EventItem): void; emit(event: T, ...args: ArgsOf): Promise; diff --git a/server/src/interfaces/logger.interface.ts b/server/src/interfaces/logger.interface.ts index 5a4f1ad9d73412..92984bf8e146a0 100644 --- a/server/src/interfaces/logger.interface.ts +++ b/server/src/interfaces/logger.interface.ts @@ -1,9 +1,9 @@ -import { LogLevel } from 'src/enum'; +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 | false): void; isLevelEnabled(level: LogLevel): boolean; diff --git a/server/src/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts index 80b37c3a5f182c..0a04840a968a5f 100644 --- a/server/src/interfaces/map.interface.ts +++ b/server/src/interfaces/map.interface.ts @@ -28,5 +28,4 @@ export interface IMapRepository { init(): Promise; reverseGeocode(point: GeoPoint): Promise; getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; - fetchStyle(url: string): Promise; } diff --git a/server/src/interfaces/oauth.interface.ts b/server/src/interfaces/oauth.interface.ts new file mode 100644 index 00000000000000..5e629726a0a76e --- /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 65814e0046f469..b3e2c0990efd11 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -1,5 +1,5 @@ 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'; @@ -55,15 +55,15 @@ export interface IPersonRepository { getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; - getAssets(personId: string): Promise; - create(person: Partial): Promise; createAll(people: Partial[]): Promise; - createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; - deleteAll(): Promise; deleteFaces(options: DeleteFacesOptions): Promise; - replaceFaces(assetId: string, entities: Partial[], sourceType?: string): Promise; + refreshFaces( + facesToAdd: Partial[], + faceIdsToRemove: string[], + embeddingsToAdd?: FaceSearchEntity[], + ): Promise; getAllFaces(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getFaceById(id: string): Promise; getFaceByIdWithAssets( diff --git a/server/src/main.ts b/server/src/main.ts index 48ce179e887536..11cc44ec10b925 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -2,20 +2,15 @@ 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/enum'; -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 execArgv = process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)); @@ -35,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/migrations/1700713871511-UsePgVectors.ts b/server/src/migrations/1700713871511-UsePgVectors.ts index 033e2ba9ad990f..e67c7275a796d6 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 e325f270fd36e7..f9ea5a0dc31109 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 bc6bad6dbdd875..d11e7b921e8f5f 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 c8e02ec0c5e5a3..ae6d752c65da99 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/queries/person.repository.sql b/server/src/queries/person.repository.sql index fa8b0910b4ff8c..5616559d7d06d1 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -248,114 +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"."status" AS "AssetEntity_status", - "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", diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 8bca755c32e268..fd47a976a529a5 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -499,39 +499,6 @@ export class AssetRepository implements IAssetRepository { }); } - getWith( - pagination: PaginationOptions, - property: WithProperty, - libraryId?: string, - withDeleted = false, - ): Paginated { - let where: FindOptionsWhere | FindOptionsWhere[] = {}; - - switch (property) { - case WithProperty.SIDECAR: { - where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; - break; - } - - default: { - throw new Error(`Invalid getWith property: ${property}`); - } - } - - if (libraryId) { - where = [{ ...where, libraryId }]; - } - - return paginate(this.repository, pagination, { - where, - withDeleted, - order: { - // Ensures correct order when paginating - createdAt: 'ASC', - }, - }); - } - getLastUpdatedAssetForAlbumId(albumId: string): Promise { return this.repository.findOne({ where: { albums: { id: albumId } }, diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts new file mode 100644 index 00000000000000..83d89c6e01d14c --- /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 index 0d8e3f76c10962..d9b7c3638421fa 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -1,24 +1,113 @@ import { Injectable } from '@nestjs/common'; -import { getVectorExtension } from 'src/database.config'; -import { ImmichEnvironment, LogLevel } from 'src/enum'; +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 { - environment: process.env.IMMICH_ENV as ImmichEnvironment, + port: Number(process.env.IMMICH_PORT) || 2283, + 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: getVectorExtension(), + 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 0453421a39d1b9..547f03fc200146 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); } @@ -71,10 +74,6 @@ export class DatabaseRepository implements IDatabaseRepository { await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`); } - async updateExtension(extension: DatabaseExtension, version?: string): Promise { - await this.dataSource.query(`ALTER EXTENSION ${extension} UPDATE${version ? ` TO '${version}'` : ''}`); - } - async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise { const { availableVersion, installedVersion } = await this.getExtensionVersion(extension); if (!installedVersion) { @@ -119,7 +118,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 +140,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 90d8e7bf5d7a8c..cb58d56b2ad72b 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; +import { ModuleRef, Reflector } from '@nestjs/core'; import { OnGatewayConnection, OnGatewayDisconnect, @@ -7,11 +7,16 @@ 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, serverEvents, @@ -24,6 +29,14 @@ import { handlePromiseError } from 'src/utils/misc'; type EmitHandlers = Partial<{ [T in EmitEvent]: Array> }>; +type Item = { + event: T; + handler: EmitHandler; + priority: number; + server: boolean; + label: string; +}; + @Instrumentation() @WebSocketGateway({ cors: true, @@ -44,6 +57,49 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect this.logger.setContext(EventRepository.name); } + setup({ services }: { services: ClassConstructor[] }) { + const reflector = this.moduleRef.get(Reflector, { strict: false }); + const repository = this.moduleRef.get(IEventRepository); + const items: Item[] = []; + + // 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']); + + // register by priority + for (const handler of handlers) { + repository.on(handler); + } + } + afterInit(server: Server) { this.logger.log('Initialized websocket server'); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 5da4f678d3237a..5bf08d0d78f1a4 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -20,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'; @@ -56,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'; @@ -94,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 }, diff --git a/server/src/repositories/logger.repository.spec.ts b/server/src/repositories/logger.repository.spec.ts new file mode 100644 index 00000000000000..dcb54ada7c0037 --- /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 08fb6e797325f0..2023cd6c4307a5 100644 --- a/server/src/repositories/logger.repository.ts +++ b/server/src/repositories/logger.repository.ts @@ -1,24 +1,40 @@ -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/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) { @@ -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 3508de720b2e16..3e5c499f41993f 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 @@ -110,20 +113,6 @@ export class MapRepository implements IMapRepository { })); } - async fetchStyle(url: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); - } - - return response.json(); - } catch (error) { - throw new Error(`Failed to fetch data from ${url}: ${error}`); - } - } - async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); @@ -181,6 +170,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 +216,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 +272,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 cca87f44f2b81b..0777ca3479a98b 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -79,13 +79,12 @@ export class MediaRepository implements IMediaRepository { 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 - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') - .withIccProfile(options.colorspace) - .rotate(); + pipeline = pipeline.rotate(); } if (options.crop) { diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index ec798145203941..dc2a4cdf9bd1f3 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -1,12 +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 { 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() @@ -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); } diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts new file mode 100644 index 00000000000000..adde7099d07206 --- /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 0350e8a953027a..c62c4b87394933 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -5,6 +5,7 @@ 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 { PaginationMode, SourceType } from 'src/enum'; import { @@ -31,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, ) {} @@ -61,10 +63,6 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.remove(entities); } - async deleteAll(): Promise { - await this.personRepository.clear(); - } - async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') @@ -232,30 +230,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 @@ -291,17 +265,32 @@ export class PersonRepository implements IPersonRepository { return results.map((person) => person.id); } - async createFaces(entities: AssetFaceEntity[]): Promise { - const res = await this.assetFaceRepository.save(entities); - return res.map((row) => row.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'); + } - 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); - }); + 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(person: Partial): Promise { diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index cb80c8d2f1c4ef..882a2634bd9dd4 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'node:crypto'; -import { getVectorExtension } from 'src/database.config'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; @@ -10,7 +9,8 @@ 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, PaginationMode } from 'src/enum'; -import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension, VectorExtension } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetDuplicateResult, @@ -26,11 +26,12 @@ import { asVector, searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; 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; @@ -42,7 +43,9 @@ export class SearchRepository implements ISearchRepository { @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) @@ -110,14 +113,6 @@ export class SearchRepository implements ISearchRepository { return assets1; } - 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: [ { page: 1, size: 100 }, @@ -133,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') @@ -440,7 +426,7 @@ export class SearchRepository implements ISearchRepository { } 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 ae04f600c07c6d..1936ecdb61aba9 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -4,7 +4,6 @@ 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'; @@ -60,7 +59,7 @@ export class ServerInfoRepository implements IServerInfoRepository { } async getBuildVersions(): Promise { - const { nodeVersion } = this.configRepository.getEnv(); + const { nodeVersion, resourcePaths } = this.configRepository.getEnv(); const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([ maybeFirstLine('node --version'), diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 6fd9bb8b041472..b95744998403f4 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -156,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, @@ -172,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, @@ -206,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/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index 30720b6c1fb29d..f9a8e6ce47bc65 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 1e4034de936fad..fce104ecbdfbf9 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,13 @@ 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 { requireAccess } from 'src/utils/access'; +import { BaseService } from 'src/services/base.service'; @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 this.requireAccess({ 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 +30,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 this.requireAccess({ 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 this.requireAccess({ auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); const common = { userId: auth.user.id, @@ -55,7 +48,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 +58,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 +69,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 this.requireAccess({ 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 b8624b29aebd5d..33c8f5dd7f624f 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', () => { @@ -318,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)); @@ -427,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 2f5d2308415ff9..e8acce9b6c878b 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,12 @@ 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 { checkAccess, requireAccess } from 'src/utils/access'; +import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; +import { BaseService } from 'src/services/base.service'; 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), @@ -95,7 +81,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 this.requireAccess({ 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 }); @@ -119,7 +105,7 @@ export class AlbumService { } } - const allowedAssetIdsSet = await checkAccess(this.access, { + const allowedAssetIdsSet = await this.checkAccess({ auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds || [], @@ -143,7 +129,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 this.requireAccess({ auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: true }); @@ -166,17 +152,17 @@ export class AlbumService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.ALBUM_DELETE, ids: [id] }); + await this.requireAccess({ 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 this.requireAccess({ 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 }, ); @@ -195,12 +181,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 this.requireAccess({ 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 }, ); @@ -216,7 +202,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 this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); @@ -260,14 +246,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 this.requireAccess({ 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 this.requireAccess({ 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 4d13eead575fc5..3841ba1be97565 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 7dd1ed5c268ba7..303ca05537781b 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 039dcb9aaeafda..66f8061d3c869f 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 c03c974b2c8e25..c269739935e01f 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -1,14 +1,17 @@ -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 { AssetStatus, AssetType, CacheControl } 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'; @@ -16,13 +19,9 @@ 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); @@ -617,5 +846,37 @@ describe(AssetMediaService.name, () => { 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, + }, + ], + }); + + expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); + }); }); }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index e1b30e891f9360..70f4905de31e48 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -1,10 +1,4 @@ -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 } from 'src/cores/storage.core'; @@ -28,14 +22,9 @@ import { import { AuthDto } from 'src/dtos/auth.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } 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 { 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 { requireAccess, requireUploadAccess } from 'src/utils/access'; +import { JobName } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; +import { requireUploadAccess } from 'src/utils/access'; import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -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 this.requireAccess({ auth, permission: Permission.ASSET_UPLOAD, // do not need an id here, but the interface requires it @@ -182,7 +159,7 @@ export class AssetMediaService { sidecarFile?: UploadFile, ): Promise { try { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const asset = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); @@ -205,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 this.requireAccess({ 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, @@ -220,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 this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; @@ -243,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 this.requireAccess({ 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'); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 968b774b770d6b..9063df9dc2a820 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -1,13 +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 { AssetStatus, AssetType } from 'src/enum'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.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'; @@ -18,16 +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 { newConfigRepositoryMock } from 'test/repositories/config.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 = { @@ -45,16 +36,15 @@ const statResponse: AssetStatsResponseDto = { describe(AssetService.name, () => { let sut: AssetService; + let accessMock: IAccessRepositoryMock; let assetMock: Mocked; - let configMock: 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(); @@ -67,29 +57,8 @@ describe(AssetService.name, () => { }; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - assetMock = newAssetRepositoryMock(); - configMock = newConfigRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - userMock = newUserRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - stackMock = newStackRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new AssetService( - accessMock, - assetMock, - configMock, - jobMock, - systemMock, - userMock, - eventMock, - partnerMock, - stackMock, - loggerMock, - ); + ({ sut, accessMock, assetMock, eventMock, jobMock, partnerMock, stackMock, systemMock, userMock } = + newTestService(AssetService)); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); }); @@ -160,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])); @@ -181,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); @@ -211,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', () => { @@ -241,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', () => { @@ -293,6 +432,42 @@ describe(AssetService.name, () => { }); }); + 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] }; @@ -332,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); @@ -388,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 }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 11416880280c68..2f31806e814449 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject } from '@nestjs/common'; +import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; import { DateTime, Duration } from 'luxon'; import { @@ -20,46 +20,19 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetStatus, Permission } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; 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, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; export class AssetService extends BaseService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(AssetService.name); - } - async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { const partnerIds = await getMyPartnerIds({ userId: auth.user.id, @@ -112,15 +85,15 @@ export class AssetService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [id] }); + await this.requireAccess({ 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, @@ -161,7 +134,7 @@ export class AssetService extends BaseService { } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const repos = { asset: this.assetRepository, event: this.eventRepository }; @@ -204,7 +177,7 @@ export class AssetService extends BaseService { 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 this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); @@ -301,7 +274,7 @@ export class AssetService extends BaseService { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { const { ids, force } = dto; - await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids }); + await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); await this.assetRepository.updateAll(ids, { deletedAt: new Date(), status: force ? AssetStatus.DELETED : AssetStatus.TRASHED, @@ -310,12 +283,17 @@ export class AssetService extends BaseService { } async run(auth: AuthDto, dto: AssetJobsDto) { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await this.requireAccess({ 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; diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index ef685f4a877559..c7a51565afa5c5 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 ced0f49c63716d..d891c88b3911c3 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -1,4 +1,4 @@ -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'; @@ -21,44 +21,23 @@ import { StorageFolder, UserPathType, } 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 { 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 { requireAccess } from 'src/utils/access'; +import { BaseService } from 'src/services/base.service'; 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 this.requireAccess({ 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, diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 7cb79b80a19056..3701d3de568777 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,35 +1,35 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; -import { Issuer, generators } from 'openid-client'; 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 } from 'src/enum'; +import { AuthType, Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { IConfigRepository } from 'src/interfaces/config.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'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; import { keyStub } from 'test/fixtures/api-key.stub'; -import { authStub, loginResponseStub } from 'test/fixtures/auth.stub'; +import { authStub } from 'test/fixtures/auth.stub'; 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 { newConfigRepositoryMock } from 'test/repositories/config.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 oauthResponse = { + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, +}; // const token = Buffer.from('my-api-key', 'utf8').toString('base64'); @@ -59,66 +59,36 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; - let configMock: Mocked; + 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); - - configMock = newConfigRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - eventMock = newEventRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - sessionMock = newSessionRepositoryMock(); - shareMock = newSharedLinkRepositoryMock(); - keyMock = newKeyRepositoryMock(); - - sut = new AuthService( - configMock, - 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); @@ -140,7 +110,15 @@ describe('AuthService', () => { it('should successfully log the user in', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); - await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); + await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, + }); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); }); @@ -297,7 +275,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' }, @@ -308,7 +286,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' }, @@ -318,8 +296,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({ @@ -331,7 +320,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({ @@ -343,11 +332,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({ @@ -359,7 +348,7 @@ describe('AuthService', () => { user: userStub.admin, sharedLink: sharedLinkStub.valid, }); - expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); + expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key); }); }); @@ -427,6 +416,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( @@ -452,6 +452,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); @@ -473,7 +487,7 @@ describe('AuthService', () => { sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); @@ -502,13 +516,29 @@ describe('AuthService', () => { sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create 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://', @@ -523,7 +553,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'); }); } @@ -534,7 +564,7 @@ describe('AuthService', () => { userMock.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -545,10 +575,10 @@ 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, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -559,10 +589,10 @@ 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, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -573,10 +603,10 @@ 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, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith({ @@ -593,10 +623,10 @@ 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, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith({ diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 3e4a55b7ff044b..8a86ad16d18b30 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -1,18 +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 { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { OnEvent } from 'src/decorators'; import { AuthDto, ChangePasswordDto, @@ -30,15 +22,7 @@ import { import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; import { AuthType, Permission } from 'src/enum'; -import { IKeyRepository } from 'src/interfaces/api-key.interface'; -import { IConfigRepository } from 'src/interfaces/config.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 { 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'; @@ -51,8 +35,6 @@ export interface LoginDetails { deviceOS: string; } -type OAuthProfile = UserinfoResponse; - interface ClaimOptions { key: string; default: T; @@ -72,21 +54,9 @@ export type ValidateRequest = { @Injectable() export class AuthService extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ISessionRepository) private sessionRepository: ISessionRepository, - @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, - @Inject(IKeyRepository) private keyRepository: IKeyRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(AuthService.name); - - custom.setHttpOptionsDefaults({ timeout: 30_000 }); + @OnEvent({ name: 'app.bootstrap' }) + onBootstrap() { + this.oauthRepository.init(); } async login(dto: LoginCredentialDto, details: LoginDetails) { @@ -212,25 +182,20 @@ export class AuthService extends BaseService { } async authorize(dto: OAuthConfigDto): Promise { - const config = await this.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, this.resolveRedirectUri(oauth, dto.redirectUri)); return { url }; } async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) { - const config = await this.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.resolveRedirectUri(oauth, dto.url)); + const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim } = oauth; this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); let user = await this.userRepository.getByOAuthId(profile.sub); @@ -288,8 +253,12 @@ export class AuthService extends BaseService { } async link(auth: AuthDto, dto: OAuthCallbackDto): Promise { - const config = await this.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.resolveRedirectUri(oauth, dto.url), + ); 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}).`); @@ -315,60 +284,7 @@ export class AuthService extends BaseService { 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 { @@ -452,4 +368,16 @@ export class AuthService extends BaseService { const value = profile[options.key as keyof OAuthProfile]; return options.isValid(value) ? (value as T) : options.default; } + + private resolveRedirectUri( + { mobileRedirectUri, mobileOverrideEnabled }: { mobileRedirectUri: string; mobileOverrideEnabled: boolean }, + url: string, + ) { + const redirectUri = url.split('?')[0]; + 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 index a2ddcb1e5000a0..2bb717b45b9c32 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -1,18 +1,102 @@ 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 { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; 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(ILoggerRepository) protected logger: ILoggerRepository, - ) {} + @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() { + private get configRepos() { return { configRepo: this.configRepository, metadataRepo: this.systemMetadataRepository, @@ -21,10 +105,18 @@ export class BaseService { } getConfig(options: { withCache: boolean }) { - return getConfig(this.repos, options); + return getConfig(this.configRepos, options); } updateConfig(newConfig: SystemConfig) { - return updateConfig(this.repos, newConfig); + return updateConfig(this.configRepos, newConfig); + } + + requireAccess(request: AccessRequest) { + return requireAccess(this.accessRepository, request); + } + + checkAccess(request: AccessRequest) { + return checkAccess(this.accessRepository, request); } } diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index f79c2d49342f95..ef520070eaeb51 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,34 +1,26 @@ -import { IConfigRepository } from 'src/interfaces/config.interface'; -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 { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; -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 configMock: Mocked; - let cryptoMock: Mocked; let userMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - configMock = newConfigRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); + ({ sut, userMock, systemMock } = newTestService(CliService)); + }); - sut = new CliService(configMock, cryptoMock, systemMock, userMock, loggerMock); + describe('listUsers', () => { + it('should list users', async () => { + userMock.getList.mockResolvedValue([userStub.admin]); + await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); + expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); + }); }); describe('resetAdminPassword', () => { @@ -69,4 +61,32 @@ describe(CliService.name, () => { expect(update.password).toBeDefined(); }); }); + + describe('disablePasswordLogin', () => { + it('should disable password login', async () => { + await sut.disablePasswordLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); + }); + }); + + describe('enablePasswordLogin', () => { + it('should enable password login', async () => { + await sut.enablePasswordLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + }); + }); + + describe('disableOAuthLogin', () => { + it('should disable oauth login', async () => { + await sut.disableOAuthLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + }); + }); + + describe('enableOAuthLogin', () => { + it('should enable oauth login', async () => { + await sut.enableOAuthLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); + }); + }); }); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 5abd1fab2906b2..18a79108c44688 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 { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -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 extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(CliService.name); - } - async listUsers(): Promise { const users = await this.userRepository.getList({ withDeleted: true }); return users.map((user) => mapUserAdmin(user)); diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 5bce6d819ccb80..96d94453c49a10 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -7,13 +7,13 @@ import { } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DatabaseService } from 'src/services/database.service'; -import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; -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; @@ -24,11 +24,7 @@ describe(DatabaseService.name, () => { let versionAboveRange: string; beforeEach(() => { - configMock = newConfigRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new DatabaseService(configMock, databaseMock, loggerMock); + ({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService)); extensionRange = '0.2.x'; databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); @@ -47,262 +43,360 @@ describe(DatabaseService.name, () => { expect(sut).toBeDefined(); }); - it('should throw an error if PostgreSQL version is below minimum supported version', async () => { - databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); + describe('onBootstrap', () => { + it('should throw an error if PostgreSQL version is below minimum supported version', async () => { + databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); - await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); - - expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); - }); + await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); - 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(() => { - configMock.getEnv.mockReturnValue( - mockEnvData({ database: { skipMigrations: false, vectorExtension: extension } }), - ); + expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); - it(`should start up successfully with ${extension}`, async () => { - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: null, - availableVersion: minVersionInRange, + 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(() => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: false, + vectorExtension: extension, + }, + }), + ); }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); - - expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); - expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + it(`should start up successfully with ${extension}`, async () => { + databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, + }); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); + expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); - it(`should throw an error if the ${extension} extension is not installed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); - const message = `The ${extensionName} extension is not available in this Postgres instance. + it(`should throw an error if the ${extension} extension is not installed`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); + const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; - await expect(sut.onBootstrap()).rejects.toThrow(message); + await expect(sut.onBootstrap()).rejects.toThrow(message); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); - it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: versionBelowRange, - availableVersion: versionBelowRange, + it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: versionBelowRange, + availableVersion: versionBelowRange, + }); + + await expect(sut.onBootstrap()).rejects.toThrow( + `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, + ); + + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow( - `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, - ); + it(`should throw an error if ${extension} extension version is a nightly`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow( + `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, + ); - it(`should throw an error if ${extension} extension version is a nightly`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); - await expect(sut.onBootstrap()).rejects.toThrow( - `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, - ); + it(`should do in-range update for ${extension} extension`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); - it(`should do in-range update for ${extension} extension`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: updateInRange, - installedVersion: minVersionInRange, + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + it(`should not upgrade ${extension} if same version`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: minVersionInRange, + }); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); - it(`should not upgrade ${extension} if same version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: minVersionInRange, - installedVersion: minVersionInRange, + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + it(`should throw error if ${extension} available version is below range`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: versionBelowRange, + installedVersion: null, + }); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow(); - it(`should throw error if ${extension} available version is below range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: versionBelowRange, - installedVersion: null, + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow(); + it(`should throw error if ${extension} available version is above range`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: versionAboveRange, + installedVersion: minVersionInRange, + }); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow(); - it(`should throw error if ${extension} available version is above range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: versionAboveRange, - installedVersion: minVersionInRange, + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow(); + it('should throw error if available version is below installed version', async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: updateInRange, + }); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow( + `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, + ); - it('should throw error if available version is below installed version', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: minVersionInRange, - installedVersion: updateInRange, + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it('should throw error if installed version is not in version range', async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: versionAboveRange, + }); + + await expect(sut.onBootstrap()).rejects.toThrow( + `The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`, + ); + + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it(`should raise error if ${extension} extension upgrade failed`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); + + expect(loggerMock.warn.mock.calls[0][0]).toContain( + `The ${extensionName} extension can be updated to ${updateInRange}.`, + ); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); + + it(`should warn if ${extension} extension update requires restart`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(loggerMock.warn).toHaveBeenCalledTimes(1); + expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it(`should reindex ${extension} indices if needed`, async () => { + databaseMock.shouldReindex.mockResolvedValue(true); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); + expect(databaseMock.reindex).toHaveBeenCalledTimes(2); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow( - `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, + it(`should throw an error if reindexing fails`, async () => { + databaseMock.shouldReindex.mockResolvedValue(true); + databaseMock.reindex.mockRejectedValue(new Error('Error reindexing')); + + await expect(sut.onBootstrap()).rejects.toBeDefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1); + expect(databaseMock.reindex).toHaveBeenCalledTimes(1); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not run vector reindexing checks.'), + ); + }); + + it(`should not reindex ${extension} indices if not needed`, async () => { + databaseMock.shouldReindex.mockResolvedValue(false); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); + expect(databaseMock.reindex).toHaveBeenCalledTimes(0); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + }); + + 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, + }, + }), ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - it(`should raise error if ${extension} extension upgrade failed`, async () => { + it(`should throw error if pgvector extension could not be created`, async () => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTOR, + }, + }), + ); databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: updateInRange, - installedVersion: minVersionInRange, + installedVersion: null, + availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.warn.mock.calls[0][0]).toContain( - `The ${extensionName} extension can be updated to ${updateInRange}.`, + expect(loggerMock.fatal).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal.mock.calls[0][0]).toContain( + `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, ); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); - it(`should warn if ${extension} extension update requires restart`, async () => { + it(`should throw error if pgvecto.rs extension could not be created`, async () => { databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: updateInRange, - installedVersion: minVersionInRange, + installedVersion: null, + availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(loggerMock.fatal).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal.mock.calls[0][0]).toContain( + `Alternatively, if your Postgres instance has pgvector, you may use this instead`, + ); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); + }); - it(`should reindex ${extension} indices if needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); - - await expect(sut.onBootstrap()).resolves.toBeUndefined(); - - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(2); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + describe('handleConnectionError', () => { + beforeAll(() => { + vi.useFakeTimers(); }); - it(`should not reindex ${extension} indices if not needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(false); + afterAll(() => { + vi.useRealTimers(); + }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + it('should not override interval', () => { + sut.handleConnectionError(new Error('Error')); + expect(loggerMock.error).toHaveBeenCalled(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(0); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + sut.handleConnectionError(new Error('foo')); + expect(loggerMock.error).toHaveBeenCalledTimes(1); }); - }); - it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - configMock.getEnv.mockReturnValue( - mockEnvData({ - database: { - skipMigrations: true, - vectorExtension: DatabaseExtension.VECTORS, - }, - }), - ); + it('should reconnect when interval elapses', async () => { + databaseMock.reconnect.mockResolvedValue(true); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + sut.handleConnectionError(new Error('error')); + await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); - it(`should throw error if pgvector extension could not be created`, async () => { - configMock.getEnv.mockReturnValue( - mockEnvData({ - database: { - skipMigrations: true, - vectorExtension: DatabaseExtension.VECTOR, - }, - }), - ); - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: null, - availableVersion: minVersionInRange, + await vi.advanceTimersByTimeAsync(5000); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - - await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( - `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, - ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); - it(`should throw error if pgvecto.rs extension could not be created`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: null, - availableVersion: minVersionInRange, + it('should try again when reconnection fails', async () => { + databaseMock.reconnect.mockResolvedValueOnce(false); + + sut.handleConnectionError(new Error('error')); + await vi.advanceTimersByTimeAsync(5000); + + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); + + databaseMock.reconnect.mockResolvedValueOnce(true); + await vi.advanceTimersByTimeAsync(5000); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(2); + expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - - await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( - `Alternatively, if your Postgres instance has pgvector, you may use this instead`, - ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index 9ba190d30afaec..363266c6aef6af 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 { OnEvent } from 'src/decorators'; -import { IConfigRepository } from 'src/interfaces/config.interface'; 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,9 @@ const messages = { const RETRY_DURATION = Duration.fromObject({ seconds: 5 }); @Injectable() -export class DatabaseService { +export class DatabaseService extends BaseService { private reconnection?: NodeJS.Timeout; - constructor( - @Inject(IConfigRepository) private configRepository: IConfigRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(DatabaseService.name); - } - @OnEvent({ name: 'app.bootstrap', priority: -200 }) async onBootstrap() { const version = await this.databaseRepository.getPostgresVersion(); diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 14fa7bab48f48a..632d15738480ce 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 988b859ff882f0..3d66f009cfb816 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,14 @@ 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 { requireAccess } from 'src/utils/access'; +import { ImmichReadStream } from 'src/interfaces/storage.interface'; +import { BaseService } from 'src/services/base.service'; 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 +61,7 @@ export class DownloadService { } async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); @@ -116,20 +104,20 @@ export class DownloadService { if (dto.assetIds) { const assetIds = dto.assetIds; - await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); + await this.requireAccess({ 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 this.requireAccess({ 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 this.requireAccess({ 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 ff03f02389db3a..095d53dde65704 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -1,6 +1,4 @@ import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.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'; @@ -8,43 +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 { newConfigRepositoryMock } from 'test/repositories/config.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 configMock: 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(); - configMock = newConfigRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - searchMock = newSearchRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - - sut = new DuplicateService(configMock, 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 f5baa611ff0446..e76b80b04391ce 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -1,22 +1,11 @@ -import { Inject, Injectable } from '@nestjs/common'; +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 { IConfigRepository } from 'src/interfaces/config.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'; @@ -24,19 +13,6 @@ import { usePagination } from 'src/utils/pagination'; @Injectable() export class DuplicateService extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(DuplicateService.name); - } - async getDuplicates(auth: AuthDto): Promise { const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 8b23e133d97a25..0353deb39b8730 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 { defaults } from 'src/config'; +import { ImmichWorker } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobCommand, @@ -12,20 +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 { newConfigRepositoryMock } from 'test/repositories/config.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) => { @@ -39,24 +28,11 @@ const makeMockHandlers = (status: JobStatus) => { describe(JobService.name, () => { let sut: JobService; let assetMock: Mocked; - let configMock: Mocked; - let eventMock: Mocked; let jobMock: Mocked; - let personMock: Mocked; - let metricMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - configMock = newConfigRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - personMock = newPersonRepositoryMock(); - metricMock = newMetricRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - sut = new JobService(assetMock, configMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock); + ({ sut, assetMock, jobMock, systemMock } = newTestService(JobService)); }); it('should work', () => { @@ -65,7 +41,7 @@ describe(JobService.name, () => { describe('onConfigUpdate', () => { it('should update concurrency', () => { - sut.onBootstrap('microservices'); + sut.onBootstrap(ImmichWorker.MICROSERVICES); sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 7ff76447968bc1..971509447f2625 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -1,15 +1,12 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { snakeCase } from 'lodash'; import { OnEvent } from 'src/decorators'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; -import { AssetType, ManualJobName } from 'src/enum'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { ArgOf, IEventRepository } from 'src/interfaces/event.interface'; +import { AssetType, ImmichWorker, ManualJobName } from 'src/enum'; +import { ArgOf } from 'src/interfaces/event.interface'; import { ConcurrentQueueName, - IJobRepository, JobCommand, JobHandler, JobItem, @@ -18,10 +15,6 @@ 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 => { @@ -48,23 +41,9 @@ const asJobItem = (dto: JobCreateDto): JobItem => { export class JobService extends BaseService { private isMicroservices = false; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(JobService.name); - } - @OnEvent({ name: 'app.bootstrap' }) onBootstrap(app: ArgOf<'app.bootstrap'>) { - this.isMicroservices = app === 'microservices'; + this.isMicroservices = app === ImmichWorker.MICROSERVICES; } @OnEvent({ name: 'config.update', server: true }) diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index 3a6c8446e2f1cc..b021eedbe901b9 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -5,8 +5,6 @@ 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 { IConfigRepository } from 'src/interfaces/config.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IJobRepository, @@ -17,7 +15,6 @@ import { 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'; @@ -26,15 +23,8 @@ 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 { newConfigRepositoryMock } from 'test/repositories/config.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() { @@ -45,37 +35,14 @@ describe(LibraryService.name, () => { let sut: LibraryService; let assetMock: Mocked; - let configMock: Mocked; - let cryptoMock: Mocked; let databaseMock: Mocked; let jobMock: Mocked; let libraryMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - configMock = newConfigRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - libraryMock = newLibraryRepositoryMock(); - assetMock = newAssetRepositoryMock(); - jobMock = newJobRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - storageMock = newStorageRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new LibraryService( - assetMock, - configMock, - cryptoMock, - databaseMock, - jobMock, - libraryMock, - storageMock, - systemMock, - loggerMock, - ); + ({ sut, assetMock, databaseMock, jobMock, libraryMock, storageMock, systemMock } = newTestService(LibraryService)); databaseMock.tryLock.mockResolvedValue(true); }); @@ -152,6 +119,64 @@ describe(LibraryService.name, () => { }); }); + describe('onConfigUpdateEvent', () => { + beforeEach(async () => { + systemMock.get.mockResolvedValue(defaults); + databaseMock.tryLock.mockResolvedValue(true); + await sut.onBootstrap(); + }); + + it('should do nothing if oldConfig is not provided', async () => { + await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + + it('should do nothing if instance does not have the watch lock', async () => { + databaseMock.tryLock.mockResolvedValue(false); + await sut.onBootstrap(); + await sut.onConfigUpdate({ newConfig: systemConfigStub.libraryScan as SystemConfig, oldConfig: defaults }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + + it('should update cron job and enable watching', async () => { + libraryMock.getAll.mockResolvedValue([]); + await sut.onConfigUpdate({ + newConfig: { + library: { ...systemConfigStub.libraryScan.library, ...systemConfigStub.libraryWatchEnabled.library }, + } as SystemConfig, + oldConfig: defaults, + }); + + expect(jobMock.updateCronJob).toHaveBeenCalledWith( + 'libraryScan', + systemConfigStub.libraryScan.library.scan.cronExpression, + systemConfigStub.libraryScan.library.scan.enabled, + ); + }); + + it('should update cron job and disable watching', async () => { + libraryMock.getAll.mockResolvedValue([]); + await sut.onConfigUpdate({ + newConfig: { + library: { ...systemConfigStub.libraryScan.library, ...systemConfigStub.libraryWatchEnabled.library }, + } as SystemConfig, + oldConfig: defaults, + }); + await sut.onConfigUpdate({ + newConfig: { + library: { ...systemConfigStub.libraryScan.library, ...systemConfigStub.libraryWatchDisabled.library }, + } as SystemConfig, + oldConfig: defaults, + }); + + expect(jobMock.updateCronJob).toHaveBeenCalledWith( + 'libraryScan', + systemConfigStub.libraryScan.library.scan.cronExpression, + systemConfigStub.libraryScan.library.scan.enabled, + ); + }); + }); + describe('onConfigValidateEvent', () => { it('should allow a valid cron expression', () => { expect(() => @@ -172,10 +197,8 @@ describe(LibraryService.name, () => { }); }); - describe('handleQueueAssetRefresh', () => { + describe('handleQueueSyncFiles', () => { it('should queue refresh of a new asset', async () => { - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.walk.mockImplementation(mockWalk); @@ -212,8 +235,6 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); @@ -529,14 +550,6 @@ describe(LibraryService.name, () => { }, }, ], - [ - { - name: JobName.VIDEO_CONVERSION, - data: { - id: assetStub.video.id, - }, - }, - ], ]); }); @@ -604,8 +617,8 @@ describe(LibraryService.name, () => { expect(jobMock.queueAll).not.toHaveBeenCalled(); }); - 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'")); + it('should fail when the file could not be read', async () => { + storageMock.stat.mockRejectedValue(new Error('Could not read file')); const mockLibraryJob: ILibraryFileJob = { id: libraryStub.externalLibrary1.id, @@ -617,6 +630,27 @@ describe(LibraryService.name, () => { assetMock.create.mockResolvedValue(assetStub.image); await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + expect(libraryMock.get).not.toHaveBeenCalled(); + expect(assetMock.create).not.toHaveBeenCalled(); + }); + + it('should skip if the file could not be found', async () => { + const error = new Error('File not found') as any; + error.code = 'ENOENT'; + storageMock.stat.mockRejectedValue(error); + + const mockLibraryJob: ILibraryFileJob = { + id: libraryStub.externalLibrary1.id, + ownerId: userStub.admin.id, + assetPath: '/data/user1/photo.jpg', + }; + + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); + assetMock.create.mockResolvedValue(assetStub.image); + + await expect(sut.handleSyncFile(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED); + expect(libraryMock.get).not.toHaveBeenCalled(); + expect(assetMock.create).not.toHaveBeenCalled(); }); }); @@ -699,6 +733,10 @@ describe(LibraryService.name, () => { expect(libraryMock.getStatistics).toHaveBeenCalledWith(libraryStub.externalLibrary1.id); }); + + it('should throw an error if the library could not be found', async () => { + await expect(sut.getStatistics('foo')).rejects.toBeInstanceOf(BadRequestException); + }); }); describe('create', () => { @@ -828,6 +866,13 @@ describe(LibraryService.name, () => { }); }); + describe('getAll', () => { + it('should get all libraries', async () => { + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); + await expect(sut.getAll()).resolves.toEqual([expect.objectContaining({ id: libraryStub.externalLibrary1.id })]); + }); + }); + describe('handleQueueCleanup', () => { it('should queue cleanup jobs', async () => { libraryMock.getAllDeleted.mockResolvedValue([libraryStub.externalLibrary1, libraryStub.externalLibrary2]); @@ -848,15 +893,38 @@ describe(LibraryService.name, () => { await sut.onBootstrap(); }); + it('should throw an error if an import path is invalid', async () => { + libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + + await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException); + expect(libraryMock.update).not.toHaveBeenCalled(); + }); + it('should update library', async () => { libraryMock.update.mockResolvedValue(libraryStub.externalLibrary1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.externalLibrary1)); + storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + storageMock.checkFileExists.mockResolvedValue(true); + + await expect(sut.update('library-id', { importPaths: ['foo/bar'] })).resolves.toEqual( + mapLibrary(libraryStub.externalLibrary1), + ); expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); }); }); + describe('onShutdown', () => { + it('should do nothing if instance does not have the watch lock', async () => { + await sut.onShutdown(); + }); + }); + describe('watchAll', () => { + it('should return false if instance does not have the watch lock', async () => { + await expect(sut.watchAll()).resolves.toBe(false); + }); + describe('watching disabled', () => { beforeEach(async () => { systemMock.get.mockResolvedValue(systemConfigStub.libraryWatchDisabled); @@ -917,6 +985,7 @@ describe(LibraryService.name, () => { it('should handle a new file event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); storageMock.watch.mockImplementation(makeMockWatcher({ items: [{ event: 'add', value: '/foo/photo.jpg' }] })); await sut.watchAll(); @@ -931,11 +1000,15 @@ describe(LibraryService.name, () => { }, }, ]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, + ]); }); it('should handle a file change event', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); storageMock.watch.mockImplementation( makeMockWatcher({ items: [{ event: 'change', value: '/foo/photo.jpg' }] }), ); @@ -952,6 +1025,24 @@ describe(LibraryService.name, () => { }, }, ]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, + ]); + }); + + it('should handle a file unlink event', async () => { + libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); + libraryMock.getAll.mockResolvedValue([libraryStub.externalLibraryWithImportPaths1]); + assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); + storageMock.watch.mockImplementation( + makeMockWatcher({ items: [{ event: 'unlink', value: '/foo/photo.jpg' }] }), + ); + + await sut.watchAll(); + + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.LIBRARY_SYNC_ASSET, data: expect.objectContaining({ id: assetStub.image.id }) }, + ]); }); it('should handle an error event', async () => { @@ -1031,15 +1122,14 @@ describe(LibraryService.name, () => { it('should delete an empty library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); assetMock.getAll.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.delete.mockImplementation(async () => {}); await expect(sut.handleDeleteLibrary({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS); + expect(libraryMock.delete).toHaveBeenCalled(); }); - it('should delete a library with assets', async () => { + it('should delete all assets in a library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); assetMock.getAll.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); - libraryMock.delete.mockImplementation(async () => {}); assetMock.getById.mockResolvedValue(assetStub.image1); @@ -1121,6 +1211,10 @@ describe(LibraryService.name, () => { }); describe('validate', () => { + it('should not require import paths', async () => { + await expect(sut.validate('library-id', {})).resolves.toEqual({ importPaths: [] }); + }); + it('should validate directory', async () => { storageMock.stat.mockResolvedValue({ isDirectory: () => true, diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index abffad81661336..a75403326de84f 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { R_OK } from 'node:constants'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; @@ -17,24 +17,16 @@ import { import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryEntity } from 'src/entities/library.entity'; import { AssetType } 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 { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { DatabaseLock } from 'src/interfaces/database.interface'; import { ArgOf } from 'src/interfaces/event.interface'; import { IEntityJob, - IJobRepository, ILibraryAssetJob, ILibraryFileJob, 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'; @@ -47,21 +39,6 @@ export class LibraryService extends BaseService { private watchLock = false; private watchers: Record Promise> = {}; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ILibraryRepository) private repository: ILibraryRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(LibraryService.name); - } - @OnEvent({ name: 'app.bootstrap' }) async onBootstrap() { const config = await this.getConfig({ withCache: false }); @@ -217,14 +194,14 @@ export class LibraryService extends BaseService { 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 ${id} not found`); } @@ -237,13 +214,13 @@ export class LibraryService extends BaseService { } 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 } })), ); @@ -251,7 +228,7 @@ export class LibraryService extends BaseService { } 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 ?? [], @@ -326,7 +303,6 @@ export class LibraryService extends BaseService { async update(id: string, dto: UpdateLibraryDto): Promise { await this.findOrFail(id); - const library = await this.repository.update({ id, ...dto }); if (dto.importPaths) { const validation = await this.validate(id, { importPaths: dto.importPaths }); @@ -339,6 +315,7 @@ export class LibraryService extends BaseService { } } + const library = await this.libraryRepository.update({ id, ...dto }); return mapLibrary(library); } @@ -349,7 +326,7 @@ export class LibraryService extends BaseService { await this.unwatch(id); } - await this.repository.softDelete(id); + await this.libraryRepository.softDelete(id); await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); } @@ -364,7 +341,10 @@ export class LibraryService extends BaseService { this.logger.debug(`Will delete all assets in library ${libraryId}`); for await (const assets of assetPagination) { - assetsFound = true; + if (assets.length > 0) { + assetsFound = true; + } + this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`); await this.jobRepository.queueAll( assets.map((asset) => ({ @@ -379,7 +359,7 @@ export class LibraryService extends BaseService { if (!assetsFound) { this.logger.log(`Deleting library ${libraryId}`); - await this.repository.delete(libraryId); + await this.libraryRepository.delete(libraryId); } return JobStatus.SUCCESS; } @@ -407,7 +387,7 @@ export class LibraryService extends BaseService { this.logger.log(`Importing new library asset: ${assetPath}`); - const library = await this.repository.get(job.id, true); + 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; @@ -454,10 +434,6 @@ export class LibraryService extends BaseService { this.logger.debug(`Queueing metadata extraction for: ${asset.originalPath}`); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); - - if (asset.type === AssetType.VIDEO) { - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } }); - } } async queueScan(id: string) { @@ -477,7 +453,7 @@ export class LibraryService extends BaseService { 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, @@ -553,7 +529,7 @@ export class LibraryService extends BaseService { } async handleQueueSyncFiles(job: IEntityJob): Promise { - const library = await this.repository.get(job.id); + const library = await this.libraryRepository.get(job.id); if (!library) { this.logger.debug(`Library ${job.id} not found, skipping refresh`); return JobStatus.SKIPPED; @@ -572,39 +548,39 @@ export class LibraryService extends BaseService { } } - if (validImportPaths) { - const assetsOnDisk = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - includeHidden: false, - exclusionPatterns: library.exclusionPatterns, - take: JOBS_LIBRARY_PAGINATION_SIZE, - }); + if (validImportPaths.length === 0) { + this.logger.warn(`No valid import paths found for library ${library.id}`); + } - let count = 0; + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); - 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}...`); - } + let count = 0; - 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}`); + 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 if (validImportPaths.length > 0) { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); } - await this.repository.update({ id: job.id, refreshedAt: new Date() }); + await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() }); return JobStatus.SUCCESS; } async handleQueueSyncAssets(job: IEntityJob): Promise { - const library = await this.repository.get(job.id); + const library = await this.libraryRepository.get(job.id); if (!library) { return JobStatus.SKIPPED; } @@ -636,7 +612,7 @@ export class LibraryService extends BaseService { } 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 e0127b73efbb9a..fde2ba7e0fe355 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -2,25 +2,22 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IMapRepository } from 'src/interfaces/map.interface'; import { IPartnerRepository } from 'src/interfaces/partner.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 { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; -import { newPartnerRepositoryMock } from 'test/repositories/partner.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 partnerMock: Mocked; let mapMock: Mocked; + let partnerMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - mapMock = newMapRepositoryMock(); - - sut = new MapService(albumMock, partnerMock, mapMock); + ({ sut, albumMock, mapMock, partnerMock } = newTestService(MapService)); }); describe('getMapMarkers', () => { @@ -42,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 3b1ee58cf124d1..860a782e79a0b2 100644 --- a/server/src/services/map.service.ts +++ b/server/src/services/map.service.ts @@ -1,18 +1,9 @@ -import { Inject } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IMapRepository } from 'src/interfaces/map.interface'; -import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; -export class MapService { - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IMapRepository) private mapRepository: IMapRepository, - ) {} - +export class MapService extends BaseService { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds = [auth.user.id]; if (options.withPartners) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index fd0de069269063..0ef065c5f452c6 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,7 +1,10 @@ import { Stats } from 'node:fs'; +import { SystemConfig } from 'src/config'; +import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { AssetFileType, + AssetPathType, AssetType, AudioCodec, Colorspace, @@ -12,9 +15,7 @@ import { VideoCodec, } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { IJobRepository, JobCounts, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -26,55 +27,24 @@ 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 { newConfigRepositoryMock } from 'test/repositories/config.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 configMock: 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(); - configMock = newConfigRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - jobMock = newJobRepositoryMock(); - mediaMock = newMediaRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); - storageMock = newStorageRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new MediaService( - assetMock, - configMock, - personMock, - jobMock, - mediaMock, - storageMock, - systemMock, - moveMock, - cryptoMock, - loggerMock, - ); + ({ sut, assetMock, jobMock, loggerMock, mediaMock, moveMock, personMock, storageMock, systemMock } = + newTestService(MediaService)); }); it('should be defined', () => { @@ -169,10 +139,10 @@ describe(MediaService.name, () => { hasNextPage: false, }); personMock.getAll.mockResolvedValue({ - items: [personStub.noThumbnail], + items: [personStub.noThumbnail, personStub.noThumbnail], hasNextPage: false, }); - personMock.getRandomFace.mockResolvedValue(faceStub.face1); + personMock.getRandomFace.mockResolvedValueOnce(faceStub.face1); await sut.handleQueueGenerateThumbnails({ force: false }); @@ -181,6 +151,7 @@ describe(MediaService.name, () => { expect(personMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { thumbnailPath: '' } }); expect(personMock.getRandomFace).toHaveBeenCalled(); + expect(personMock.update).toHaveBeenCalledTimes(1); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.GENERATE_PERSON_THUMBNAIL, @@ -264,6 +235,46 @@ describe(MediaService.name, () => { }); }); + describe('handleQueueMigration', () => { + it('should remove empty directories and queue jobs', async () => { + assetMock.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); + jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); + personMock.getAll.mockResolvedValue({ hasNextPage: false, items: [personStub.withName] }); + + await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); + + expect(storageMock.removeEmptyDirs).toHaveBeenCalledTimes(2); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } }, + ]); + expect(jobMock.queueAll).toHaveBeenCalledWith([ + { name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } }, + ]); + }); + }); + + describe('handleAssetMigration', () => { + it('should fail if asset does not exist', async () => { + await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); + + expect(moveMock.getByEntity).not.toHaveBeenCalled(); + }); + + it('should move asset files', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + moveMock.create.mockResolvedValue({ + entityId: assetStub.image.id, + id: 'move-id', + newPath: '/new/path', + oldPath: '/old/path', + pathType: AssetPathType.ORIGINAL, + }); + + await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + expect(moveMock.create).toHaveBeenCalledTimes(2); + }); + }); + describe('handleGenerateThumbnails', () => { let rawBuffer: Buffer; let rawInfo: RawImageInfo; @@ -281,10 +292,19 @@ describe(MediaService.name, () => { expect(assetMock.update).not.toHaveBeenCalledWith(); }); + it('should skip thumbnail generation if asset type is unknown', async () => { + assetMock.getById.mockResolvedValue({ ...assetStub.image, type: 'foo' } as never as AssetEntity); + + await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); + expect(mediaMock.probe).not.toHaveBeenCalled(); + expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); + expect(assetMock.update).not.toHaveBeenCalledWith(); + }); + it('should skip video thumbnail generation if no video stream', async () => { mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams); - assetMock.getByIds.mockResolvedValue([assetStub.video]); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + assetMock.getById.mockResolvedValue(assetStub.video); + await expect(sut.handleGenerateThumbnails({ id: assetStub.video.id })).rejects.toBeInstanceOf(Error); expect(mediaMock.generateThumbnail).not.toHaveBeenCalled(); expect(assetMock.update).not.toHaveBeenCalledWith(); }); @@ -786,6 +806,27 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).not.toHaveBeenCalled(); }); + it('should throw an error if an unknown transcode policy is configured', async () => { + mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toBeDefined(); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + + it('should throw an error if transcoding fails and hw acceleration is disabled', async () => { + mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); + systemMock.get.mockResolvedValue({ + ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED }, + }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + mediaMock.transcode.mockRejectedValue(new Error('Error transcoding video')); + + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); + expect(mediaMock.transcode).toHaveBeenCalledTimes(1); + }); + it('should transcode when set to all', async () => { mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); @@ -817,7 +858,7 @@ describe(MediaService.name, () => { ); }); - it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => { + it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -832,6 +873,21 @@ describe(MediaService.name, () => { ); }); + it('should transcode when max bitrate is not a number', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); + 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.any(Array), + twoPass: false, + }), + ); + }); + it('should not scale resolution if no target resolution', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' } }); @@ -1266,7 +1322,7 @@ describe(MediaService.name, () => { expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ - '-c:v av1', + '-c:v libsvtav1', '-movflags faststart', '-fps_mode passthrough', '-map 0:0', @@ -1635,12 +1691,13 @@ describe(MediaService.name, () => { }); it('should fail for qsv if no hw devices', async () => { - storageMock.readdir.mockResolvedValue([]); + storageMock.readdir.mockRejectedValue(new Error('Could not read directory')); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); systemMock.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); assetMock.getByIds.mockResolvedValue([assetStub.video]); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); expect(mediaMock.transcode).not.toHaveBeenCalled(); + expect(loggerMock.debug).toHaveBeenCalledWith('No devices found in /dev/dri.'); }); it('should use hardware decoding for qsv if enabled', async () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index adb8c54f4a797e..f270e21b6f931a 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } 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'; @@ -17,31 +17,17 @@ import { VideoCodec, VideoContainer, } from 'src/enum'; -import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +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, - TranscodeCommand, - 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'; @@ -50,36 +36,9 @@ import { usePagination } from 'src/utils/pagination'; @Injectable() export class MediaService extends BaseService { - private storageCore: StorageCore; private maliOpenCL?: boolean; private devices?: string[]; - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(MediaService.name); - this.storageCore = StorageCore.create( - assetRepository, - configRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force @@ -395,13 +354,9 @@ export class MediaService extends BaseService { private getTranscodeTarget( config: SystemConfigFFmpegDto, - videoStream?: VideoStreamInfo, + videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo, ): TranscodeTarget { - if (!videoStream && !audioStream) { - return TranscodeTarget.NONE; - } - const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream); const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream); @@ -443,11 +398,7 @@ export class MediaService extends BaseService { } } - private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean { - if (!stream) { - return false; - } - + private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean { const scalingEnabled = ffmpegConfig.targetResolution !== 'original'; const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes; diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index ba184daa801bf9..b5dd4c2553f4aa 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 fb1ff49f0b4562..816b0fddeb0fb8 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -1,28 +1,21 @@ -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 { checkAccess, requireAccess } from 'src/utils/access'; +import { BaseService } from 'src/services/base.service'; 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 this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); const memory = await this.findOrFail(id); return mapMemory(memory); } @@ -31,12 +24,12 @@ export class MemoryService { // TODO validate type/data combination const assetIds = dto.assetIds || []; - const allowedAssetIds = await checkAccess(this.access, { + const allowedAssetIds = await this.checkAccess({ 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 +43,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 this.requireAccess({ 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 +56,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 this.requireAccess({ 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 this.requireAccess({ 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 this.requireAccess({ 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 +86,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 88b2498e91b875..cd7f68ab1dd8c5 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -3,19 +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 { 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, 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'; @@ -28,23 +24,7 @@ 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 { 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 { 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, () => { @@ -52,60 +32,37 @@ describe(MetadataService.name, () => { let albumMock: Mocked; let assetMock: Mocked; - let configMock: Mocked; - let cryptoRepository: Mocked; - let databaseMock: 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 systemMock: Mocked; let tagMock: Mocked; let userMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - assetMock = newAssetRepositoryMock(); - configMock = newConfigRepositoryMock(); - 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, - configMock, - cryptoRepository, - databaseMock, + cryptoMock, eventMock, jobMock, mapMock, mediaMock, metadataMock, - moveMock, personMock, storageMock, systemMock, tagMock, userMock, - loggerMock, - ); + } = newTestService(MetadataService)); + + delete process.env.TZ; }); afterEach(async () => { @@ -118,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); @@ -128,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(); @@ -292,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(); }); @@ -310,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, @@ -320,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] }); 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, @@ -345,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' }), ); @@ -365,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 })); }); @@ -527,7 +505,9 @@ describe(MetadataService.name, () => { mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); + 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(); @@ -553,7 +533,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).toHaveBeenCalledWith( expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }), ); @@ -569,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); @@ -581,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', @@ -612,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); @@ -624,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', @@ -656,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), @@ -700,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); @@ -725,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); @@ -747,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); @@ -773,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); @@ -813,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), @@ -871,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', @@ -891,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({ @@ -912,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({ @@ -934,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({ @@ -956,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({ @@ -1020,11 +1006,10 @@ describe(MetadataService.name, () => { metadataMock.readTags.mockResolvedValue(metadataStub.withFaceNoName); personMock.getDistinctNames.mockResolvedValue([]); personMock.createAll.mockResolvedValue([]); - personMock.replaceFaces.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).toHaveBeenCalledWith([]); - expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); - expect(personMock.updateAll).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 () => { @@ -1033,11 +1018,10 @@ describe(MetadataService.name, () => { metadataMock.readTags.mockResolvedValue(metadataStub.withFaceEmptyName); personMock.getDistinctNames.mockResolvedValue([]); personMock.createAll.mockResolvedValue([]); - personMock.replaceFaces.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(personMock.createAll).toHaveBeenCalledWith([]); - expect(personMock.replaceFaces).toHaveBeenCalledWith(assetStub.primaryImage.id, [], SourceType.EXIF); - expect(personMock.updateAll).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 () => { @@ -1046,14 +1030,12 @@ describe(MetadataService.name, () => { metadataMock.readTags.mockResolvedValue(metadataStub.withFace); personMock.getDistinctNames.mockResolvedValue([]); personMock.createAll.mockResolvedValue([personStub.withName.id]); - personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); 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.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: personStub.withName.name })]); - expect(personMock.replaceFaces).toHaveBeenCalledWith( - assetStub.primaryImage.id, + expect(personMock.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1068,7 +1050,7 @@ describe(MetadataService.name, () => { sourceType: SourceType.EXIF, }, ], - SourceType.EXIF, + [], ); expect(personMock.updateAll).toHaveBeenCalledWith([{ id: 'random-uuid', faceAssetId: 'random-uuid' }]); expect(jobMock.queueAll).toHaveBeenCalledWith([ @@ -1085,14 +1067,12 @@ describe(MetadataService.name, () => { metadataMock.readTags.mockResolvedValue(metadataStub.withFace); personMock.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]); personMock.createAll.mockResolvedValue([]); - personMock.replaceFaces.mockResolvedValue(['face-asset-uuid']); 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.createAll).toHaveBeenCalledWith([]); - expect(personMock.replaceFaces).toHaveBeenCalledWith( - assetStub.primaryImage.id, + expect(personMock.createAll).not.toHaveBeenCalled(); + expect(personMock.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', @@ -1107,10 +1087,10 @@ describe(MetadataService.name, () => { sourceType: SourceType.EXIF, }, ], - SourceType.EXIF, + [], ); - expect(personMock.updateAll).toHaveBeenCalledWith([]); - expect(jobMock.queueAll).toHaveBeenCalledWith([]); + expect(personMock.updateAll).not.toHaveBeenCalled(); + expect(jobMock.queueAll).not.toHaveBeenCalledWith(); }); it('should handle invalid modify date', async () => { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3995c72f770e5a..a81d1b4904c275 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; @@ -12,33 +12,21 @@ 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 { IConfigRepository } from 'src/interfaces/config.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ArgOf, 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, ReverseGeocodeResult } 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'; @@ -99,44 +87,9 @@ const validateRange = (value: number | undefined, min: number, max: number): Non @Injectable() export class MetadataService extends BaseService { - private storageCore: StorageCore; - - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @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) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(MetadataService.name); - this.storageCore = StorageCore.create( - assetRepository, - configRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { - if (app !== 'microservices') { + if (app !== ImmichWorker.MICROSERVICES) { return; } const config = await this.getConfig({ withCache: false }); @@ -145,7 +98,7 @@ export class MetadataService extends BaseService { @OnEvent({ name: 'app.shutdown' }) async onShutdown() { - await this.repository.teardown(); + await this.metadataRepository.teardown(); } @OnEvent({ name: 'config.update' }) @@ -225,7 +178,7 @@ export class MetadataService extends BaseService { async handleMetadataExtraction({ id }: IEntityJob): Promise { const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); - const [asset] = await this.assetRepository.getByIds([id]); + const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } }); if (!asset) { return JobStatus.FAILED; } @@ -372,7 +325,7 @@ export class MetadataService extends BaseService { return JobStatus.SKIPPED; } - await this.repository.writeTags(sidecarPath, exif); + await this.metadataRepository.writeTags(sidecarPath, exif); if (!asset.sidecarPath) { await this.assetRepository.update({ id, sidecarPath }); @@ -382,8 +335,8 @@ export class MetadataService extends BaseService { } private async getExifTags(asset: AssetEntity): Promise { - const mediaTags = await this.repository.readTags(asset.originalPath); - const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {}; + 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) : {}; // make sure dates comes from sidecar @@ -467,11 +420,11 @@ export class MetadataService extends BaseService { // 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 { @@ -560,7 +513,7 @@ export class MetadataService extends BaseService { 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[] = []; @@ -588,7 +541,7 @@ export class MetadataService extends BaseService { 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 }); @@ -597,31 +550,33 @@ export class MetadataService extends BaseService { 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 newPersonIds = await this.personRepository.createAll(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.updateAll(missingWithFaceAsset); + if (facesToRemove.length > 0 || facesToAdd.length > 0) { + await this.personRepository.refreshFaces(facesToAdd, facesToRemove); + } - await this.jobRepository.queueAll( - newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } })), - ); + if (missingWithFaceAsset.length > 0) { + await this.personRepository.updateAll(missingWithFaceAsset); + } } 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}`); - // created - let dateTimeOriginal = dateTime?.toDate(); - if (!dateTimeOriginal) { - this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`); - dateTimeOriginal = asset.fileCreatedAt; - } - // timezone let timeZone = exifTags.tz ?? null; if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { @@ -636,14 +591,16 @@ export class MetadataService extends BaseService { this.logger.warn(`Asset ${asset.id} has no time zone information`); } - // offset minutes - const offsetMinutes = dateTime?.tzoffsetMinutes || 0; - let localDateTime = dateTimeOriginal; - if (offsetMinutes) { - localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000); - this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`); + 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; } + this.logger.debug(`Asset ${asset.id} has a local time of ${localDateTime.toISOString()}`); + let modifyDate = asset.fileModifiedAt; try { modifyDate = (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? modifyDate; diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 23604b6ef6fb95..d1d2bb8f20d762 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 { 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'; @@ -45,7 +46,7 @@ export class MicroservicesService { @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { - if (app !== 'microservices') { + if (app !== ImmichWorker.MICROSERVICES) { return; } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 6b3c9e6895f8d3..028e512b3968dd 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,10 +6,8 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -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 { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; @@ -17,15 +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 { newConfigRepositoryMock } from 'test/repositories/config.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 = { @@ -66,39 +56,19 @@ const configs = { }; describe(NotificationService.name, () => { + let sut: NotificationService; + let albumMock: Mocked; let assetMock: Mocked; - let configMock: 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(); - configMock = newConfigRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - notificationMock = newNotificationRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - - sut = new NotificationService( - configMock, - eventMock, - systemMock, - notificationMock, - userMock, - jobMock, - loggerMock, - assetMock, - albumMock, - ); + ({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } = + newTestService(NotificationService)); }); it('should work', () => { @@ -156,6 +126,14 @@ 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', () => { @@ -210,6 +188,18 @@ 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' }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index dce13e5f6c60e2..f6b338d79e716a 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -1,25 +1,18 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants'; 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 { IConfigRepository } from 'src/interfaces/config.interface'; -import { ArgOf, 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'; @@ -28,21 +21,6 @@ import { getPreferences } from 'src/utils/preferences'; @Injectable() export class NotificationService extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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) logger: ILoggerRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(NotificationService.name); - } - @OnEvent({ name: 'config.update' }) onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) { this.eventRepository.clientBroadcast('on_config_update'); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index b2b3401251cb6e..2e11c4f9adecb9 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 4b7cd4c516e428..ee36f1ce45ee1e 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -1,43 +1,37 @@ -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 { requireAccess } from 'src/utils/access'; +import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; +import { BaseService } from 'src/services/base.service'; @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 +40,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 this.requireAccess({ 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 eb5362d62b57c7..da4656be021a82 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -4,13 +4,10 @@ import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.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'; @@ -22,19 +19,8 @@ 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 { newConfigRepositoryMock } from 'test/repositories/config.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'; @@ -49,69 +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 configMock: 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(); - configMock = newConfigRepositoryMock(); - 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, - configMock, + cryptoMock, + jobMock, machineLearningMock, - moveMock, mediaMock, personMock, - systemMock, - storageMock, - jobMock, searchMock, - cryptoMock, - loggerMock, - ); + storageMock, + systemMock, + } = newTestService(PersonService)); }); it('should be defined', () => { @@ -209,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); @@ -247,7 +210,6 @@ describe(PersonService.name, () => { it("should update a person's name", async () => { personMock.update.mockResolvedValue(personStub.withName); - personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); @@ -258,7 +220,6 @@ describe(PersonService.name, () => { it("should update a person's date of birth", async () => { personMock.update.mockResolvedValue(personStub.withBirthDate); - personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { birthDate: '1976-06-30' })).resolves.toEqual({ @@ -277,7 +238,6 @@ describe(PersonService.name, () => { it('should update a person visibility', async () => { personMock.update.mockResolvedValue(personStub.withName); - personMock.getAssets.mockResolvedValue([assetStub.image]); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); @@ -481,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([ @@ -497,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([ { @@ -512,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 () => { @@ -574,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([ { @@ -695,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); @@ -737,7 +721,6 @@ describe(PersonService.name, () => { '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); - expect(personMock.createFaces).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); @@ -749,29 +732,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 () => { + 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(); @@ -786,7 +813,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { @@ -797,7 +823,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { @@ -807,7 +832,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should match existing person', async () => { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 3b71d3504e33ba..e5f016d8ef24d8 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,8 +1,7 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.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, @@ -21,6 +20,7 @@ import { } from 'src/dtos/person.dto'; 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 { AssetType, @@ -31,15 +31,11 @@ import { SourceType, SystemMetadataKey, } from 'src/enum'; -import { IAccessRepository } from 'src/interfaces/access.interface'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { WithoutProperty } from 'src/interfaces/asset.interface'; import { IBaseJob, IDeferrableJob, IEntityJob, - IJobRepository, INightlyJob, JOBS_ASSET_PAGINATION_SIZE, JobItem, @@ -47,16 +43,10 @@ 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 { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -66,37 +56,6 @@ import { IsNull } from 'typeorm'; @Injectable() export class PersonService extends BaseService { - private storageCore: StorageCore; - - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IMediaRepository) private mediaRepository: IMediaRepository, - @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(PersonService.name); - this.storageCore = StorageCore.create( - assetRepository, - configRepository, - cryptoRepository, - moveRepository, - repository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - async getAll(auth: AuthDto, dto: PersonSearchDto): Promise { const { withHidden = false, page, size } = dto; const pagination = { @@ -105,11 +64,11 @@ export class PersonService extends BaseService { }; const { machineLearning } = await this.getConfig({ withCache: false }); - const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, { + 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)), @@ -120,15 +79,15 @@ export class PersonService extends BaseService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await this.requireAccess({ 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 this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); if (person.faceAssetId === null) { changeFeaturePhoto.push(person.id); } @@ -136,7 +95,7 @@ export class PersonService extends BaseService { changeFeaturePhoto.push(face.person.id); } - await this.repository.reassignFace(face.id, personId); + await this.personRepository.reassignFace(face.id, personId); } result.push(person); @@ -149,12 +108,12 @@ export class PersonService extends BaseService { } 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 this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await this.requireAccess({ 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]); } @@ -166,8 +125,8 @@ export class PersonService extends BaseService { } 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 this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.id] }); + const faces = await this.personRepository.getFaces(dto.id); return faces.map((asset) => mapFaces(asset, auth)); } @@ -178,10 +137,10 @@ export class PersonService extends BaseService { 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 } }); } } @@ -190,18 +149,18 @@ export class PersonService extends BaseService { } async getById(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] }); + await this.requireAccess({ 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 this.requireAccess({ 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 this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); + const person = await this.personRepository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); } @@ -213,14 +172,8 @@ export class PersonService extends BaseService { }); } - 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)); - } - create(auth: AuthDto, dto: PersonCreateDto): Promise { - return this.repository.create({ + return this.personRepository.create({ ownerId: auth.user.id, name: dto.name, birthDate: dto.birthDate, @@ -229,14 +182,14 @@ export class PersonService extends BaseService { } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await this.requireAccess({ 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 this.requireAccess({ 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'); } @@ -244,7 +197,7 @@ export class PersonService extends BaseService { 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 } }); @@ -274,12 +227,12 @@ export class PersonService extends BaseService { 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`); } async handlePersonCleanup(): Promise { - const people = await this.repository.getAllWithoutFaces(); + const people = await this.personRepository.getAllWithoutFaces(); await this.delete(people); return JobStatus.SUCCESS; } @@ -291,19 +244,19 @@ export class PersonService extends BaseService { } if (force) { - await this.repository.deleteFaces({ 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) { @@ -312,6 +265,10 @@ export class PersonService extends BaseService { ); } + if (force === undefined) { + await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); + } + return JobStatus.SUCCESS; } @@ -330,11 +287,11 @@ export class PersonService extends BaseService { }; 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; } @@ -343,39 +300,82 @@ export class PersonService extends BaseService { 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.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { @@ -387,7 +387,7 @@ export class PersonService extends BaseService { 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) { @@ -399,7 +399,7 @@ export class PersonService extends BaseService { const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); if (force) { - await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); await this.handlePersonCleanup(); } else if (waiting) { this.logger.debug( @@ -410,8 +410,8 @@ export class PersonService extends BaseService { 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,7 +432,7 @@ export class PersonService extends BaseService { 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 } }, @@ -457,7 +457,7 @@ export class PersonService extends BaseService { 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, @@ -481,7 +481,7 @@ export class PersonService extends BaseService { 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, @@ -496,21 +496,21 @@ export class PersonService extends BaseService { 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,13 +526,13 @@ export class PersonService extends BaseService { 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; @@ -572,7 +572,7 @@ export class PersonService extends BaseService { }; await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath); - await this.repository.update({ id: person.id, thumbnailPath }); + await this.personRepository.update({ id: person.id, thumbnailPath }); return JobStatus.SUCCESS; } @@ -583,13 +583,13 @@ export class PersonService extends BaseService { throw new BadRequestException('Cannot merge a person into themselves'); } - await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await this.requireAccess({ 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 this.checkAccess({ auth, permission: Permission.PERSON_MERGE, ids: mergeIds, @@ -603,7 +603,7 @@ export class PersonService extends BaseService { } 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; @@ -619,14 +619,14 @@ export class PersonService extends BaseService { } 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}`); @@ -640,7 +640,7 @@ export class PersonService extends BaseService { } 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 eb30717a3ad8d1..e0b03f31aee3bd 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 { IConfigRepository } from 'src/interfaces/config.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.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 { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; -import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.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 configMock: Mocked; - let systemMock: Mocked; - let machineMock: Mocked; let personMock: Mocked; let searchMock: Mocked; - let partnerMock: Mocked; - let loggerMock: Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); - configMock = newConfigRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - machineMock = newMachineLearningRepositoryMock(); - personMock = newPersonRepositoryMock(); - searchMock = newSearchRepositoryMock(); - partnerMock = newPartnerRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new SearchService( - configMock, - systemMock, - machineMock, - personMock, - searchMock, - assetMock, - partnerMock, - loggerMock, - ); + ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); }); it('should work', () => { diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index b878b4e89808e2..03ffbe97db14e1 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +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'; @@ -16,34 +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 { IConfigRepository } from 'src/interfaces/config.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMachineLearningRepository } from 'src/interfaces/machine-learning.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 extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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(ILoggerRepository) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(SearchService.name); - } - async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); } @@ -108,7 +87,11 @@ export class SearchService extends BaseService { 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( diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 18e7bde1dc4797..ab6eb3b1a4f059 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,41 +1,20 @@ import { SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -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 { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; -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 configMock: Mocked; + let storageMock: Mocked; - let userMock: Mocked; - let serverInfoMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; - let cryptoMock: Mocked; + let userMock: Mocked; beforeEach(() => { - configMock = newConfigRepositoryMock(); - storageMock = newStorageRepositoryMock(); - userMock = newUserRepositoryMock(); - serverInfoMock = newServerInfoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - - sut = new ServerService(configMock, userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock); + ({ sut, storageMock, systemMock, userMock } = newTestService(ServerService)); }); it('should work', () => { diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index ffab0c5a893dc8..3fc319a2fd938f 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -1,5 +1,4 @@ -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 } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; @@ -15,13 +14,7 @@ import { UsageByUserDto, } from 'src/dtos/server.dto'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -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 { 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'; @@ -29,19 +22,6 @@ import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchE @Injectable() export class ServerService extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(ServerService.name); - } - @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); @@ -55,7 +35,7 @@ export class ServerService extends BaseService { 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); @@ -181,20 +161,13 @@ export class ServerService extends BaseService { 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 ca3d2fd858fb0c..49d122771210d1 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 47abf3c3802461..2e27942c663a1d 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,24 +1,13 @@ -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 { requireAccess } from 'src/utils/access'; +import { BaseService } from 'src/services/base.service'; @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 +33,7 @@ export class SessionService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); + await this.requireAccess({ 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 28afe94b9f2e20..d0959f31b8f4ab 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -3,42 +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 { IConfigRepository } from 'src/interfaces/config.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 { 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 { newConfigRepositoryMock } from 'test/repositories/config.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 configMock: Mocked; - let cryptoMock: Mocked; - let shareMock: Mocked; - let systemMock: Mocked; - let logMock: Mocked; + let sharedLinkMock: Mocked; beforeEach(() => { - accessMock = newAccessRepositoryMock(); - configMock = newConfigRepositoryMock(); - cryptoMock = newCryptoRepositoryMock(); - shareMock = newSharedLinkRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - logMock = newLoggerRepositoryMock(); - - sut = new SharedLinkService(accessMock, configMock, cryptoMock, logMock, shareMock, systemMock); + ({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService)); }); it('should work', () => { @@ -47,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); }); }); @@ -126,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 }); @@ -134,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, @@ -150,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, @@ -164,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, @@ -180,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, @@ -194,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, @@ -211,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, @@ -232,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( @@ -268,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' }], }); @@ -277,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'] }), @@ -294,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 f2b0ea3c659ab6..a01a2f45a32c6b 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,4 +1,4 @@ -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 { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; @@ -14,32 +14,13 @@ 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 { IConfigRepository } from 'src/interfaces/config.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 extends BaseService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(SharedLinkService.name); - } - - getAll(auth: AuthDto): Promise { - return this.repository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link))); + 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 +48,7 @@ export class SharedLinkService extends BaseService { if (!dto.albumId) { throw new BadRequestException('Invalid albumId'); } - await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); break; } @@ -76,13 +57,13 @@ export class SharedLinkService extends BaseService { throw new BadRequestException('Invalid assetIds'); } - await requireAccess(this.access, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); + await this.requireAccess({ 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 +82,7 @@ export class SharedLinkService extends BaseService { 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 +97,12 @@ export class SharedLinkService extends BaseService { 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 +118,7 @@ export class SharedLinkService extends BaseService { 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 this.checkAccess({ auth, permission: Permission.ASSET_SHARE, ids: notPresentAssetIds, @@ -161,7 +142,7 @@ export class SharedLinkService extends BaseService { sharedLink.assets.push({ id: assetId } as AssetEntity); } - await this.repository.update(sharedLink); + await this.sharedLinkRepository.update(sharedLink); return results; } @@ -185,7 +166,7 @@ export class SharedLinkService extends BaseService { sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId); } - await this.repository.update(sharedLink); + await this.sharedLinkRepository.update(sharedLink); return results; } diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index be9fab54c69d30..f53822a9e2251f 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,9 +1,8 @@ import { SystemConfig } from 'src/config'; +import { ImmichWorker } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.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'; @@ -11,47 +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 { newConfigRepositoryMock } from 'test/repositories/config.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 configMock: 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(); - configMock = newConfigRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - searchMock = newSearchRepositoryMock(); - jobMock = newJobRepositoryMock(); - machineMock = newMachineLearningRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new SmartInfoService( - assetMock, - configMock, - databaseMock, - jobMock, - machineMock, - searchMock, - systemMock, - loggerMock, - ); + ({ sut, assetMock, databaseMock, jobMock, machineLearningMock, searchMock, systemMock } = + newTestService(SmartInfoService)); assetMock.getByIds.mockResolvedValue([assetStub.image]); }); @@ -91,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(); @@ -106,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(); @@ -121,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); @@ -137,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); @@ -152,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); @@ -313,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 () => { @@ -322,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' }), @@ -343,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 6f24dafbfee523..778f40c9316182 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,23 +1,18 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; -import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; +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'; @@ -25,23 +20,9 @@ import { usePagination } from 'src/utils/pagination'; @Injectable() export class SmartInfoService extends BaseService { - constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(SmartInfoService.name); - } - @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(app: ArgOf<'app.bootstrap'>) { - if (app !== 'microservices') { + if (app !== ImmichWorker.MICROSERVICES) { return; } @@ -72,7 +53,7 @@ export class SmartInfoService extends BaseService { 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 extends BaseService { `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) { @@ -112,7 +93,7 @@ export class SmartInfoService extends BaseService { } if (force) { - await this.repository.deleteAllSearchEmbeddings(); + await this.searchRepository.deleteAllSearchEmbeddings(); } const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { @@ -150,7 +131,7 @@ export class SmartInfoService extends BaseService { 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 extends BaseService { 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.spec.ts b/server/src/services/stack.service.spec.ts new file mode 100644 index 00000000000000..4e8813145cd895 --- /dev/null +++ b/server/src/services/stack.service.spec.ts @@ -0,0 +1,193 @@ +import { BadRequestException } from '@nestjs/common'; +import { IEventRepository } from 'src/interfaces/event.interface'; +import { IStackRepository } from 'src/interfaces/stack.interface'; +import { StackService } from 'src/services/stack.service'; +import { assetStub, stackStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newTestService } from 'test/utils'; +import { Mocked } from 'vitest'; + +describe(StackService.name, () => { + let sut: StackService; + + let accessMock: IAccessRepositoryMock; + let eventMock: Mocked; + let stackMock: Mocked; + + beforeEach(() => { + ({ sut, accessMock, eventMock, stackMock } = newTestService(StackService)); + }); + + it('should be defined', () => { + expect(sut).toBeDefined(); + }); + + describe('search', () => { + it('should search stacks', async () => { + stackMock.search.mockResolvedValue([stackStub('stack-id', [assetStub.image])]); + + await sut.search(authStub.admin, { primaryAssetId: assetStub.image.id }); + expect(stackMock.search).toHaveBeenCalledWith({ + ownerId: authStub.admin.user.id, + primaryAssetId: assetStub.image.id, + }); + }); + }); + + describe('create', () => { + it('should require asset.update permissions', async () => { + await expect( + sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); + expect(stackMock.create).not.toHaveBeenCalled(); + }); + + it('should create a stack', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id, assetStub.image1.id])); + stackMock.create.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + await expect( + sut.create(authStub.admin, { assetIds: [assetStub.image.id, assetStub.image1.id] }), + ).resolves.toEqual({ + id: 'stack-id', + primaryAssetId: assetStub.image.id, + assets: [ + expect.objectContaining({ id: assetStub.image.id }), + expect.objectContaining({ id: assetStub.image1.id }), + ], + }); + + expect(eventMock.emit).toHaveBeenCalledWith('stack.create', { + stackId: 'stack-id', + userId: authStub.admin.user.id, + }); + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalled(); + }); + }); + + describe('get', () => { + it('should require stack.read permissions', async () => { + await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(stackMock.getById).not.toHaveBeenCalled(); + }); + + it('should fail if stack could not be found', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + + await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(Error); + + expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + }); + + it('should get stack', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + + await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({ + id: 'stack-id', + primaryAssetId: assetStub.image.id, + assets: [ + expect.objectContaining({ id: assetStub.image.id }), + expect.objectContaining({ id: assetStub.image1.id }), + ], + }); + expect(accessMock.stack.checkOwnerAccess).toHaveBeenCalled(); + expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + }); + }); + + describe('update', () => { + it('should require stack.update permissions', async () => { + await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException); + + expect(stackMock.getById).not.toHaveBeenCalled(); + expect(stackMock.update).not.toHaveBeenCalled(); + expect(eventMock.emit).not.toHaveBeenCalled(); + }); + + it('should fail if stack could not be found', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + + await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error); + + expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(stackMock.update).not.toHaveBeenCalled(); + expect(eventMock.emit).not.toHaveBeenCalled(); + }); + + it('should fail if the provided primary asset id is not in the stack', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + + await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf( + BadRequestException, + ); + + expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(stackMock.update).not.toHaveBeenCalled(); + expect(eventMock.emit).not.toHaveBeenCalled(); + }); + + it('should update stack', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + stackMock.getById.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + stackMock.update.mockResolvedValue(stackStub('stack-id', [assetStub.image, assetStub.image1])); + + await sut.update(authStub.admin, 'stack-id', { primaryAssetId: assetStub.image1.id }); + + expect(stackMock.getById).toHaveBeenCalledWith('stack-id'); + expect(stackMock.update).toHaveBeenCalledWith({ id: 'stack-id', primaryAssetId: assetStub.image1.id }); + expect(eventMock.emit).toHaveBeenCalledWith('stack.update', { + stackId: 'stack-id', + userId: authStub.admin.user.id, + }); + }); + }); + + describe('delete', () => { + it('should require stack.delete permissions', async () => { + await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException); + + expect(stackMock.delete).not.toHaveBeenCalled(); + expect(eventMock.emit).not.toHaveBeenCalled(); + }); + + it('should delete stack', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + + await sut.delete(authStub.admin, 'stack-id'); + + expect(stackMock.delete).toHaveBeenCalledWith('stack-id'); + expect(eventMock.emit).toHaveBeenCalledWith('stack.delete', { + stackId: 'stack-id', + userId: authStub.admin.user.id, + }); + }); + }); + + describe('deleteAll', () => { + it('should require stack.delete permissions', async () => { + await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(BadRequestException); + + expect(stackMock.deleteAll).not.toHaveBeenCalled(); + expect(eventMock.emit).not.toHaveBeenCalled(); + }); + + it('should delete all stacks', async () => { + accessMock.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); + + await sut.deleteAll(authStub.admin, { ids: ['stack-id'] }); + + expect(stackMock.deleteAll).toHaveBeenCalledWith(['stack-id']); + expect(eventMock.emit).toHaveBeenCalledWith('stacks.delete', { + stackIds: ['stack-id'], + userId: authStub.admin.user.id, + }); + }); + }); +}); diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index 29a598d4b413a3..58fccc8be27ddf 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -1,21 +1,12 @@ -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 { IEventRepository } from 'src/interfaces/event.interface'; -import { IStackRepository } from 'src/interfaces/stack.interface'; -import { requireAccess } from 'src/utils/access'; +import { BaseService } from 'src/services/base.service'; @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,7 +17,7 @@ export class StackService { } async create(auth: AuthDto, dto: StackCreateDto): Promise { - await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); @@ -36,13 +27,13 @@ export class StackService { } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.STACK_READ, ids: [id] }); + await this.requireAccess({ 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 this.requireAccess({ 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'); @@ -56,13 +47,13 @@ export class StackService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: [id] }); await this.stackRepository.delete(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 this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: dto.ids }); await this.stackRepository.deleteAll(dto.ids); await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id }); } diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index aa127c2afc64b2..6e5af3baf97238 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -4,13 +4,9 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.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,66 +14,30 @@ 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 { newConfigRepositoryMock } from 'test/repositories/config.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 configMock: 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(); - configMock = newConfigRepositoryMock(); - 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, - configMock, - systemMock, - moveMock, - personMock, - storageMock, - userMock, - cryptoMock, - databaseMock, - loggerMock, - ); - sut.onConfigUpdate({ newConfig: defaults }); }); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index c0bf11b186a28e..e400981f541c5b 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import handlebar from 'handlebars'; import { DateTime } from 'luxon'; import path from 'node:path'; @@ -16,19 +16,9 @@ import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { IConfigRepository } from 'src/interfaces/config.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 { 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,7 +37,6 @@ interface RenderMetadata { @Injectable() export class StorageTemplateService extends BaseService { - private storageCore: StorageCore; private _template: { compiled: HandlebarsTemplateDelegate; raw: string; @@ -61,33 +50,6 @@ export class StorageTemplateService extends BaseService { return this._template; } - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(StorageTemplateService.name); - this.storageCore = StorageCore.create( - assetRepository, - configRepository, - cryptoRepository, - moveRepository, - personRepository, - storageRepository, - systemMetadataRepository, - this.logger, - ); - } - @OnEvent({ name: 'config.update', server: true }) onConfigUpdate({ newConfig }: ArgOf<'config.update'>) { const template = newConfig.storageTemplate.template; diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index e0717df66860e5..a4903a3987b10f 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,33 +1,23 @@ import { SystemMetadataKey } from 'src/enum'; import { IConfigRepository } from 'src/interfaces/config.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.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 { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; -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 configMock: Mocked; - let databaseMock: Mocked; - let storageMock: Mocked; let loggerMock: Mocked; + let storageMock: Mocked; let systemMock: Mocked; beforeEach(() => { - configMock = newConfigRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - storageMock = newStorageRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - - sut = new StorageService(configMock, databaseMock, storageMock, loggerMock, systemMock); + ({ sut, configMock, loggerMock, storageMock, systemMock } = newTestService(StorageService)); }); it('should work', () => { @@ -71,6 +61,25 @@ describe(StorageService.name, () => { 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( diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index e2c1ef28e20ed1..e8620b4371dd02 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,36 +1,25 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; +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'; + +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.`; @Injectable() -export class StorageService { - constructor( - @Inject(IConfigRepository) private configRepository: IConfigRepository, - @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 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 (enabled=${enabled})`); @@ -49,7 +38,7 @@ export class StorageService { if (!flags.mountFiles) { flags.mountFiles = true; - await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags); + await this.systemMetadataRepository.set(SystemMetadataKey.SYSTEM_FLAGS, flags); this.logger.log('Successfully enabled system mount folders checks'); } diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index a0ded6dba36267..8dc270d020555c 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 7da3fbd9be58db..f85200db489fad 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,32 +1,20 @@ -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 { requireAccess } from 'src/utils/access'; +import { BaseService } from 'src/services/base.service'; 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 this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const assets = await this.assetRepository.getAllForUserFullSync({ ownerId: userId, updatedUntil: dto.updatedUntil, @@ -50,7 +38,7 @@ export class SyncService { return FULL_SYNC; } - await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); + await this.requireAccess({ 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 5782443c758b2f..52a5b1dcd8b433 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -18,10 +18,8 @@ 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 { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; -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'; @@ -189,18 +187,14 @@ const updatedConfig = Object.freeze({ describe(SystemConfigService.name, () => { let sut: SystemConfigService; + let configMock: Mocked; - let systemMock: Mocked; let eventMock: Mocked; let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - configMock = newConfigRepositoryMock(); - eventMock = newEventRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new SystemConfigService(configMock, eventMock, systemMock, loggerMock); + ({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService)); }); it('should work', () => { diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index ff749f1105546a..96a1f0897bb361 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; import { defaults } from 'src/config'; @@ -14,26 +14,13 @@ import { } from 'src/constants'; import { OnEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { ArgOf, IEventRepository } 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 extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(SystemConfigService.name); - } - @OnEvent({ name: 'app.bootstrap', priority: -100 }) async onBootstrap() { const config = await this.getConfig({ withCache: false }); diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index 5799ee859d8c6e..3dc2f0a6bb7f0e 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 c2c9a4fdfc8c20..93449c7a7b5d7c 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 a479a09fbb48e1..54cef40d042c13 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'])); }); @@ -266,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 cc6d64f749d203..5534d74efa63e2 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,29 +12,21 @@ 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 { JobStatus } from 'src/interfaces/job.interface'; -import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface'; -import { checkAccess, requireAccess } from 'src/utils/access'; +import { AssetTagItem } from 'src/interfaces/tag.interface'; +import { BaseService } from 'src/services/base.service'; 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 this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [id] }); const tag = await this.findOrFail(id); return mapTag(tag); } @@ -42,8 +34,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 this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); + parent = (await this.tagRepository.get(dto.parentId)) || undefined; if (!parent) { throw new BadRequestException('Tag not found'); } @@ -51,41 +43,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 this.requireAccess({ 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 this.requireAccess({ 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 }), + this.checkAccess({ auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), + this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), ]); const items: AssetTagItem[] = []; @@ -95,7 +87,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 }); } @@ -104,11 +96,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 this.requireAccess({ 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 }, ); @@ -122,11 +114,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 this.requireAccess({ 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 }, ); @@ -140,12 +132,12 @@ export class TagService { } async handleTagCleanup() { - await this.repository.deleteEmptyTags(); + 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 981fc11c3f5ab2..db6890c27bd0d4 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 bc08505b944ebb..04fd206fe7cbe8 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,26 +1,17 @@ -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 { requireAccess } from 'src/utils/access'; +import { TimeBucketOptions } from 'src/interfaces/asset.interface'; +import { BaseService } from 'src/services/base.service'; 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 +20,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 +47,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 this.requireAccess({ 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 this.requireAccess({ 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 this.requireAccess({ auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); } } if (dto.tagId) { - await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); + await this.requireAccess({ 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 d0c719ae48e733..748faa14abdc29 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -1,37 +1,25 @@ import { BadRequestException } from '@nestjs/common'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ITrashRepository } from 'src/interfaces/trash.interface'; import { TrashService } from 'src/services/trash.service'; import { authStub } from 'test/fixtures/auth.stub'; -import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.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 { newTrashRepositoryMock } from 'test/repositories/trash.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 eventMock: Mocked; let jobMock: Mocked; let trashMock: Mocked; - let loggerMock: Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(() => { - accessMock = newAccessRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - trashMock = newTrashRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new TrashService(accessMock, eventMock, jobMock, trashMock, loggerMock); + ({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService)); }); describe('restoreAssets', () => { diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 51771d38a2aa9e..91c359392eecd2 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -1,35 +1,20 @@ -import { Inject } from '@nestjs/common'; 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 { IEventRepository } from 'src/interfaces/event.interface'; -import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; -import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { ITrashRepository } from 'src/interfaces/trash.interface'; -import { requireAccess } from 'src/utils/access'; +import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; import { usePagination } from 'src/utils/pagination'; -export class TrashService { - constructor( - @Inject(IAccessRepository) private access: IAccessRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ITrashRepository) private trashRepository: ITrashRepository, - @Inject(ILoggerRepository) private logger: ILoggerRepository, - ) { - this.logger.setContext(TrashService.name); - } - +export class TrashService extends BaseService { async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise { const { ids } = dto; if (ids.length === 0) { return { count: 0 }; } - await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids }); + await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); await this.trashRepository.restoreAll(ids); await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id }); diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 8e80aa4dc109af..70999332dc26a7 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 75dff32f160a94..94608a24ac0359 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { SALT_ROUNDS } from 'src/constants'; import { AuthDto } from 'src/dtos/auth.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; @@ -11,28 +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 { - 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.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)); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 4a121dfda2c764..767d8d895453a2 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -2,10 +2,7 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IConfigRepository } from 'src/interfaces/config.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'; @@ -14,14 +11,7 @@ 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 { newConfigRepositoryMock } from 'test/repositories/config.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) => { @@ -32,36 +22,15 @@ const makeDeletedAt = (daysAgo: number) => { describe(UserService.name, () => { let sut: UserService; - let userMock: Mocked; - let cryptoRepositoryMock: Mocked; let albumMock: Mocked; - let configMock: Mocked; let jobMock: Mocked; let storageMock: Mocked; let systemMock: Mocked; - let loggerMock: Mocked; + let userMock: Mocked; beforeEach(() => { - albumMock = newAlbumRepositoryMock(); - configMock = newConfigRepositoryMock(); - cryptoRepositoryMock = newCryptoRepositoryMock(); - jobMock = newJobRepositoryMock(); - storageMock = newStorageRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - userMock = newUserRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new UserService( - albumMock, - configMock, - 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 92c9c299944948..f67d04cbd3405b 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,6 +1,5 @@ -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 } from 'src/cores/storage.core'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -11,34 +10,14 @@ import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUse import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; -import { IAlbumRepository } from 'src/interfaces/album.interface'; -import { IConfigRepository } from 'src/interfaces/config.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 { 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 extends BaseService { - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IConfigRepository) configRepository: IConfigRepository, - @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) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(UserService.name); - } - async search(): Promise { const users = await this.userRepository.getList({ withDeleted: false }); return users.map((user) => mapUser(user)); @@ -153,16 +132,18 @@ export class UserService extends BaseService { 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) { diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index ebc5d4b2322f8f..46f8f620c474aa 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,8 +1,8 @@ import { DateTime } from 'luxon'; +import { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; import { IConfigRepository } from 'src/interfaces/config.interface'; -import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -10,14 +10,8 @@ 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 { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.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 { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; -import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; -import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; const mockRelease = (version: string) => ({ @@ -32,35 +26,18 @@ const mockRelease = (version: string) => ({ describe(VersionService.name, () => { let sut: VersionService; + let configMock: Mocked; - let databaseMock: Mocked; let eventMock: Mocked; let jobMock: Mocked; - let serverMock: Mocked; - let systemMock: Mocked; - let versionMock: Mocked; let loggerMock: Mocked; + let serverInfoMock: Mocked; + let systemMock: Mocked; + let versionHistoryMock: Mocked; beforeEach(() => { - configMock = newConfigRepositoryMock(); - databaseMock = newDatabaseRepositoryMock(); - eventMock = newEventRepositoryMock(); - jobMock = newJobRepositoryMock(); - serverMock = newServerInfoRepositoryMock(); - systemMock = newSystemMetadataRepositoryMock(); - versionMock = newVersionHistoryRepositoryMock(); - loggerMock = newLoggerRepositoryMock(); - - sut = new VersionService( - configMock, - databaseMock, - eventMock, - jobMock, - serverMock, - systemMock, - versionMock, - loggerMock, - ); + ({ sut, configMock, eventMock, jobMock, loggerMock, serverInfoMock, systemMock, versionHistoryMock } = + newTestService(VersionService)); }); it('should work', () => { @@ -70,17 +47,17 @@ describe(VersionService.name, () => { describe('onBootstrap', () => { it('should record a new version', async () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); + expect(versionHistoryMock.create).toHaveBeenCalledWith({ version: expect.any(String) }); }); it('should skip a duplicate version', async () => { - versionMock.getLatest.mockResolvedValue({ + versionHistoryMock.getLatest.mockResolvedValue({ id: 'version-1', createdAt: new Date(), version: serverVersion.toString(), }); await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(versionMock.create).not.toHaveBeenCalled(); + expect(versionHistoryMock.create).not.toHaveBeenCalled(); }); }); @@ -97,7 +74,7 @@ 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' }; - versionMock.getAll.mockResolvedValue([upgrade]); + versionHistoryMock.getAll.mockResolvedValue([upgrade]); await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]); }); }); @@ -127,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', @@ -140,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), @@ -150,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 60ea388e5daf10..231ced1a950af6 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import semver, { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; @@ -6,14 +6,9 @@ import { OnEvent } from 'src/decorators'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; -import { IConfigRepository } from 'src/interfaces/config.interface'; -import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; -import { ArgOf, 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 { 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 => { @@ -27,20 +22,6 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re @Injectable() export class VersionService extends BaseService { - constructor( - @Inject(IConfigRepository) configRepository: IConfigRepository, - @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, - @Inject(IEventRepository) private eventRepository: IEventRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(IServerInfoRepository) private repository: IServerInfoRepository, - @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, - @Inject(IVersionHistoryRepository) private versionRepository: IVersionHistoryRepository, - @Inject(ILoggerRepository) logger: ILoggerRepository, - ) { - super(configRepository, systemMetadataRepository, logger); - this.logger.setContext(VersionService.name); - } - @OnEvent({ name: 'app.bootstrap' }) async onBootstrap(): Promise { await this.handleVersionCheck(); @@ -91,7 +72,8 @@ export class VersionService extends BaseService { } } - 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); diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 8d17e4d8974014..e9373ce66fb7f3 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -3,7 +3,7 @@ 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 { newViewRepositoryMock } from 'test/repositories/view.repository.mock'; +import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; @@ -12,9 +12,7 @@ describe(ViewService.name, () => { let viewMock: Mocked; beforeEach(() => { - viewMock = newViewRepositoryMock(); - - sut = new ViewService(viewMock); + ({ sut, viewMock } = newTestService(ViewService)); }); it('should work', () => { diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts index d870f9fd2e1a68..cb805368705df8 100644 --- a/server/src/services/view.service.ts +++ b/server/src/services/view.service.ts @@ -1,11 +1,8 @@ -import { Inject } from '@nestjs/common'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { IViewRepository } from 'src/interfaces/view.interface'; - -export class ViewService { - constructor(@Inject(IViewRepository) private viewRepository: IViewRepository) {} +import { BaseService } from 'src/services/base.service'; +export class ViewService extends BaseService { getUniqueOriginalPaths(auth: AuthDto): Promise { return this.viewRepository.getUniqueOriginalPaths(auth.user.id); } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 498dd3456b9320..55e4fcb0e555b9 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'; @@ -91,7 +92,6 @@ export function searchAssetBuilder( withPeople, withSmartInfo, personIds, - withExif, withStacked, trashedAfter, trashedBefore, @@ -128,15 +128,16 @@ 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'); + + builder.getQuery(); // typeorm mixes up parameters without this (੭ °ཀ°)੭ } if (withStacked) { diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts deleted file mode 100644 index fbac5545789dfe..00000000000000 --- a/server/src/utils/events.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { ModuleRef, Reflector } from '@nestjs/core'; -import _ from 'lodash'; -import { EventConfig } from 'src/decorators'; -import { MetadataKey } from 'src/enum'; -import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; -import { services } from 'src/services'; - -type Item = { - event: T; - handler: EmitHandler; - priority: number; - server: boolean; - 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 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']); - - // register by priority - for (const handler of handlers) { - repository.on(handler); - } - - return handlers; -}; diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts index d4eb02ead21ab5..2e33a7bcb557aa 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 6f0ab4ef81d90b..9ad0f9440494dd 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -492,6 +492,10 @@ export class VP9Config extends BaseConfig { } export class AV1Config extends BaseConfig { + getVideoCodec(): string { + return 'libsvtav1'; + } + getPresetOptions() { const speed = this.getPresetIndex() + 4; // Use 4 as slowest, giving us an effective range of 4-12 which is far more useful than 0-8 if (speed >= 0) { diff --git a/server/src/utils/workers.spec.ts b/server/src/utils/workers.spec.ts deleted file mode 100644 index 1e4ff5e2d3694e..00000000000000 --- 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 14daa2620f833f..00000000000000 --- 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 7e7384f95fec8a..7535a902b80d6c 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -5,13 +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, 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'; @@ -32,15 +32,13 @@ 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 } = configRepository.getEnv(); + 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]); @@ -76,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 789b6f5287bbd4..3cb478057ccd50 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/start.sh b/server/start.sh index 5aa7ee01b207ad..518d9229a874fa 100755 --- a/server/start.sh +++ b/server/start.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash +echo "Initializing Immich $IMMICH_SOURCE_REF" + lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" export LD_PRELOAD="$lib_path" diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index c2c59a8007c0ae..3d2899d3c6820d 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -155,55 +155,4 @@ export const albumStub = { isActivityEnabled: true, order: AssetOrder.DESC, }), - emptyWithInvalidThumbnail: Object.freeze({ - id: 'album-5', - albumName: 'Empty album with invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [], - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), - oneAssetInvalidThumbnail: Object.freeze({ - id: 'album-6', - albumName: 'Album with one asset and invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image], - albumThumbnailAsset: assetStub.livePhotoMotionAsset, - albumThumbnailAssetId: assetStub.livePhotoMotionAsset.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), - oneAssetValidThumbnail: Object.freeze({ - id: 'album-6', - albumName: 'Album with one asset and invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image], - albumThumbnailAsset: assetStub.image, - albumThumbnailAssetId: assetStub.image.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), }; diff --git a/server/test/fixtures/api-key.stub.ts b/server/test/fixtures/api-key.stub.ts index 954c8f35a006ee..f8b1832c84e37d 100644 --- a/server/test/fixtures/api-key.stub.ts +++ b/server/test/fixtures/api-key.stub.ts @@ -11,7 +11,3 @@ export const keyStub = { user: userStub.admin, } as APIKeyEntity), }; - -export const apiKeyCreateStub = { - name: 'API Key', -}; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 119c0b6e5ab76c..45390cf92ecd46 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -523,37 +523,6 @@ export const assetStub = { }, } as AssetEntity), - liveMotionWithThumb: Object.freeze({ - id: fileStub.livePhotoMotion.uuid, - status: AssetStatus.ACTIVE, - originalPath: fileStub.livePhotoMotion.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.VIDEO, - isVisible: false, - fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - files: [ - { - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: '/uploads/user-id/thumbs/path.ext', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - }, - { - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: '/uploads/user-id/webp/path.ext', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - }, - ], - exifInfo: { - fileSizeInByte: 100_000, - timeZone: `America/New_York`, - }, - } as AssetEntity), - livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', status: AssetStatus.ACTIVE, @@ -570,22 +539,6 @@ export const assetStub = { }, } as AssetEntity), - livePhotoStillAssetWithTheSameLivePhotoMotionAsset: Object.freeze({ - id: 'live-photo-still-asset-1', - status: AssetStatus.ACTIVE, - originalPath: fileStub.livePhotoStill.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.IMAGE, - livePhotoVideoId: 'live-photo-motion-asset', - isVisible: true, - fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - exifInfo: { - fileSizeInByte: 25_000, - timeZone: `America/New_York`, - }, - } as AssetEntity), - livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', status: AssetStatus.ACTIVE, @@ -645,6 +598,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + sidecar: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -679,6 +633,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + sidecarWithoutExt: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -751,45 +706,7 @@ export const assetStub = { 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'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('file 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, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'photo', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - isOffline: false, - }), + hasFileExtension: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -829,6 +746,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + imageDng: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -868,6 +786,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + hasEmbedding: Object.freeze({ id: 'asset-id-embedding', status: AssetStatus.ACTIVE, @@ -909,6 +828,7 @@ export const assetStub = { }, isOffline: false, }), + hasDupe: Object.freeze({ id: 'asset-id-dupe', status: AssetStatus.ACTIVE, diff --git a/server/test/fixtures/audit.stub.ts b/server/test/fixtures/audit.stub.ts index 3e79a60819a15f..24f78a17ce9e71 100644 --- a/server/test/fixtures/audit.stub.ts +++ b/server/test/fixtures/audit.stub.ts @@ -3,22 +3,6 @@ import { DatabaseAction, EntityType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const auditStub = { - create: Object.freeze({ - id: 1, - entityId: 'asset-created', - action: DatabaseAction.CREATE, - entityType: EntityType.ASSET, - ownerId: authStub.admin.user.id, - createdAt: new Date(), - }), - update: Object.freeze({ - id: 2, - entityId: 'asset-updated', - action: DatabaseAction.UPDATE, - entityType: EntityType.ASSET, - ownerId: authStub.admin.user.id, - createdAt: new Date(), - }), delete: Object.freeze({ id: 3, entityId: 'asset-deleted', diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index bbb53d4db6254d..2989c0cce1b81a 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -35,17 +35,6 @@ export const authStub = { id: 'token-id', } as SessionEntity, }), - external1: Object.freeze({ - user: { - id: 'user-id', - email: 'immich@test.com', - isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - session: { - id: 'token-id', - } as SessionEntity, - }), adminSharedLink: Object.freeze({ user: { id: 'admin_id', @@ -76,20 +65,6 @@ export const authStub = { key: Buffer.from('shared-link-key'), } as SharedLinkEntity, }), - readonlySharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - sharedLink: { - id: '123', - allowUpload: false, - allowDownload: false, - showExif: true, - } as SharedLinkEntity, - }), passwordSharedLink: Object.freeze({ user: { id: 'admin_id', @@ -106,35 +81,3 @@ export const authStub = { } as SharedLinkEntity, }), }; - -export const loginResponseStub = { - admin: { - response: { - accessToken: expect.any(String), - name: 'Immich Admin', - isAdmin: true, - profileImagePath: '', - shouldChangePassword: true, - userEmail: 'admin@immich.app', - userId: expect.any(String), - }, - }, - user1oauth: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - user1password: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, -}; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 27ca2a4356e226..b8c68d5bf428cb 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -51,21 +51,6 @@ export const faceStub = { sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] }, }), - mergeFace2: Object.freeze>({ - id: 'assetFaceId4', - assetId: assetStub.image1.id, - asset: assetStub.image1, - personId: personStub.mergePerson.id, - person: personStub.mergePerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] }, - }), start: Object.freeze>({ id: 'assetFaceId5', assetId: assetStub.image.id, @@ -141,4 +126,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/library.stub.ts b/server/test/fixtures/library.stub.ts index 1a83ffe5d749a8..b2e132da3e9bc6 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -1,6 +1,3 @@ -import { join } from 'node:path'; -import { APP_MEDIA_LOCATION } from 'src/constants'; -import { THUMBNAIL_DIR } from 'src/cores/storage.core'; import { LibraryEntity } from 'src/entities/library.entity'; import { userStub } from 'test/fixtures/user.stub'; @@ -53,18 +50,6 @@ export const libraryStub = { refreshedAt: null, exclusionPatterns: [], }), - externalLibraryWithExclusionPattern: Object.freeze({ - id: 'library-id', - name: 'test_library', - assets: [], - owner: userStub.admin, - ownerId: 'user-id', - importPaths: [], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: ['**/dir1/**'], - }), patternPath: Object.freeze({ id: 'library-id1337', name: 'importpath-exclusion-library1', @@ -83,7 +68,7 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'user-id', - importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')], + importPaths: ['upload/thumbs', '/xyz', 'upload/library'], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 3584d0486ea92b..544894b31e1f4d 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -44,20 +44,6 @@ export const personStub = { faceAsset: null, isHidden: false, }), - noBirthDate: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - ownerId: userStub.admin.id, - owner: userStub.admin, - name: 'Person 1', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - }), withBirthDate: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index f237e1dea942c3..e446a6180b65a0 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -309,21 +309,6 @@ export const sharedLinkResponseStub = { type: SharedLinkType.ALBUM, userId: 'admin_id', }), - readonly: Object.freeze({ - id: '123', - userId: 'admin_id', - key: sharedLinkBytes.toString('base64url'), - type: SharedLinkType.ALBUM, - createdAt: today, - expiresAt: tomorrow, - description: null, - password: null, - allowUpload: false, - allowDownload: false, - showMetadata: true, - album: albumResponse, - assets: [assetResponse], - }), readonlyNoMetadata: Object.freeze({ id: '123', userId: 'admin_id', diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 6f3a819eef80e9..b65cd6b3958223 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -2,30 +2,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; -export const userDto = { - user1: { - email: 'user1@immich.app', - password: 'Password123', - name: 'User 1', - }, - user2: { - email: 'user2@immich.app', - password: 'Password123', - name: 'User 2', - }, - user3: { - email: 'user3@immich.app', - password: 'Password123', - name: 'User 3', - }, - userWithQuota: { - email: 'quota-user@immich.app', - password: 'Password123', - name: 'User with quota', - quotaSizeInBytes: 42, - }, -}; - export const userStub = { admin: Object.freeze({ ...authStub.admin.user, @@ -100,22 +76,6 @@ export const userStub = { quotaSizeInBytes: null, quotaUsageInBytes: 0, }), - externalPathRoot: Object.freeze({ - ...authStub.user1.user, - password: 'immich_password', - name: 'immich_name', - storageLabel: 'label-1', - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }), profilePath: Object.freeze({ ...authStub.user1.user, password: 'immich_password', diff --git a/server/test/medium/metadata.service.spec.ts b/server/test/medium/metadata.service.spec.ts new file mode 100644 index 00000000000000..3ccce0f16e725a --- /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/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 50fff31e55e4c5..982273ff69b965 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -17,7 +17,6 @@ export const newAssetRepositoryMock = (): Mocked => { getByChecksum: vitest.fn(), getByChecksums: vitest.fn(), getUploadAssetIdByChecksum: vitest.fn(), - getWith: vitest.fn(), getRandom: vitest.fn(), getLastUpdatedAssetForAlbumId: vitest.fn(), getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index e61185225f8424..852868ee315543 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -1,19 +1,52 @@ -import { ImmichEnvironment } from 'src/enum'; +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: 2283, 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 => { diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index 0e1d4ab3e71dd5..da6417a38c565a 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -9,7 +9,6 @@ export const newDatabaseRepositoryMock = (): Mocked => { getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), getPostgresVersionRange: vitest.fn().mockReturnValue('>=14.0.0'), createExtension: vitest.fn().mockResolvedValue(void 0), - updateExtension: vitest.fn(), updateVectorExtension: vitest.fn(), reindex: vitest.fn(), shouldReindex: vitest.fn(), diff --git a/server/test/repositories/event.repository.mock.ts b/server/test/repositories/event.repository.mock.ts index 6893b29f49a6f3..23f54080051828 100644 --- a/server/test/repositories/event.repository.mock.ts +++ b/server/test/repositories/event.repository.mock.ts @@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newEventRepositoryMock = (): Mocked => { return { + setup: vitest.fn(), on: vitest.fn() as any, emit: vitest.fn() as any, clientSend: vitest.fn() as any, diff --git a/server/test/repositories/map.repository.mock.ts b/server/test/repositories/map.repository.mock.ts index 95965522e34d85..703e8696f10d34 100644 --- a/server/test/repositories/map.repository.mock.ts +++ b/server/test/repositories/map.repository.mock.ts @@ -6,6 +6,5 @@ export const newMapRepositoryMock = (): Mocked => { init: vitest.fn(), reverseGeocode: vitest.fn(), getMapMarkers: vitest.fn(), - fetchStyle: 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 00000000000000..f87b3781e955f9 --- /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 6ffe7bf97be1c0..d7b92d3eab498f 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -6,7 +6,6 @@ export const newPersonRepositoryMock = (): Mocked => { getById: vitest.fn(), getAll: vitest.fn(), getAllForUser: vitest.fn(), - getAssets: vitest.fn(), getAllWithoutFaces: vitest.fn(), getByName: vitest.fn(), @@ -17,7 +16,6 @@ export const newPersonRepositoryMock = (): Mocked => { update: vitest.fn(), updateAll: vitest.fn(), delete: vitest.fn(), - deleteAll: vitest.fn(), deleteFaces: vitest.fn(), getStatistics: vitest.fn(), @@ -27,8 +25,7 @@ export const newPersonRepositoryMock = (): Mocked => { 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/utils.ts b/server/test/utils.ts new file mode 100644 index 00000000000000..3b7e80994d62ca --- /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 00000000000000..40dad8d6a5024a --- /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 1013b4606df3f5..92fc027d40fb9e 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: 80, + lines: 85, + statements: 85, + branches: 90, + functions: 85, }, }, server: { diff --git a/web/.nvmrc b/web/.nvmrc index 3516580bbbc04b..2a393af592b8cd 100644 --- a/web/.nvmrc +++ b/web/.nvmrc @@ -1 +1 @@ -20.17.0 +20.18.0 diff --git a/web/Dockerfile b/web/Dockerfile index 19d8d890ab55e1..cac593b17f02da 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-alpine3.20@sha256:2d07db07a2df6830718ae2a47db6fedce6745f5bcd174c398f2acdda90a11c03 +FROM node:20.18.0-alpine3.20@sha256:c13b26e7e602ef2f1074aef304ce6e9b7dd284c419b35d89fcf3cc8e44a8def9 RUN apk add --no-cache tini USER node diff --git a/web/package-lock.json b/web/package-lock.json index 88dc9be27eea90..193475c1d3a2b0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,17 +1,17 @@ { "name": "immich-web", - "version": "1.116.2", + "version": "1.118.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.2", + "version": "1.118.1", "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.3.0", + "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -74,13 +74,13 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.2", + "version": "1.118.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" }, "devDependencies": { - "@types/node": "^20.16.9", + "@types/node": "^20.16.11", "typescript": "^5.3.3" } }, @@ -1471,6 +1471,13 @@ "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", @@ -1480,10 +1487,23 @@ } }, "node_modules/@mapbox/mapbox-gl-rtl-text": { - "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" + "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" + } }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", @@ -1911,16 +1931,16 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.28", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.28.tgz", - "integrity": "sha512-/O7pvFGBsQPcFa9UrW8eUC5uHTOXLsUp3SN0dY6YmRAL9nfPSrJsSJk//j5vMpinSshzUjteAFcfQTU+04Ka1w==", + "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", @@ -2176,9 +2196,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": { @@ -2335,17 +2355,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz", - "integrity": "sha512-RIHOoznhA3CCfSTFiB6kBGLQtB/sox+pJ6jeFu6FxJvqL8qRxq/FfGO/UhsGgQM9oGdXkV4xUgli+dt26biB6A==", + "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.7.0", - "@typescript-eslint/type-utils": "8.7.0", - "@typescript-eslint/utils": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -2369,16 +2389,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.7.0.tgz", - "integrity": "sha512-lN0btVpj2unxHlNYLI//BQ7nzbMJYBVQX5+pbNXvGYazdlgYonMn4AhhHifQ+J4fGRYA/m1DjaQjx+fDetqBOQ==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.0", - "@typescript-eslint/visitor-keys": "8.7.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": { @@ -2398,14 +2418,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz", - "integrity": "sha512-87rC0k3ZlDOuz82zzXRtQ7Akv3GKhHs0ti4YcbAJtaomllXoSO8hi7Ix3ccEvCd824dy9aIX+j3d2UMAfCtVpg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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" @@ -2416,14 +2436,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.7.0.tgz", - "integrity": "sha512-tl0N0Mj3hMSkEYhLkjREp54OSb/FI6qyCzfiiclvJvOqre6hsZTGSnHtmFLDU8TIM62G7ygEa1bI08lcuRwEnQ==", + "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.7.0", - "@typescript-eslint/utils": "8.7.0", + "@typescript-eslint/typescript-estree": "8.8.0", + "@typescript-eslint/utils": "8.8.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2441,9 +2461,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.7.0.tgz", - "integrity": "sha512-LLt4BLHFwSfASHSF2K29SZ+ZCsbQOM+LuarPjRUuHm+Qd09hSe3GCeaQbcCr+Mik+0QFRmep/FyZBO6fJ64U3w==", + "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": { @@ -2455,14 +2475,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz", - "integrity": "sha512-MC8nmcGHsmfAKxwnluTQpNqceniT8SteVwd2voYlmiSWGOtjvGXdPl17dYu2797GVscK30Z04WRM28CrKS9WOg==", + "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.7.0", - "@typescript-eslint/visitor-keys": "8.7.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", @@ -2510,16 +2530,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.7.0.tgz", - "integrity": "sha512-ZbdUdwsl2X/s3CiyAu3gOlfQzpbuG3nTWKPoIvAu1pu5r8viiJvv2NPN2AqArL35NCYtw/lrPPfM4gxrMLNLPw==", + "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.7.0", - "@typescript-eslint/types": "8.7.0", - "@typescript-eslint/typescript-estree": "8.7.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 +2553,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz", - "integrity": "sha512-b1tx0orFCCh/THWPQa2ZwWzvOeyzzp36vkJYOpVg0u8UVOIsfVrnuC9FqAw9gRKn+rG2VmWQ/zDJZzkxUnj/XQ==", + "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.7.0", + "@typescript-eslint/types": "8.8.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -2551,9 +2571,9 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.1.tgz", - "integrity": "sha512-md/A7A3c42oTT8JUHSqjP5uKTWJejzUW4jalpvs+rZ27gsURsMU8DEb+8Jf8C6Kj2gwfSHJqobDNBuoqlm0cFw==", + "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": { @@ -2574,8 +2594,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "2.1.1", - "vitest": "2.1.1" + "@vitest/browser": "2.1.2", + "vitest": "2.1.2" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2584,14 +2604,14 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.1.tgz", - "integrity": "sha512-YeueunS0HiHiQxk+KEOnq/QMzlUuOzbU1Go+PgAsHvvv3tUkJPm9xWt+6ITNTlzsMXUjmgm5T+U7KBPK2qQV6w==", + "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.1.1", - "@vitest/utils": "2.1.1", + "@vitest/spy": "2.1.2", + "@vitest/utils": "2.1.2", "chai": "^5.1.1", "tinyrainbow": "^1.2.0" }, @@ -2600,9 +2620,9 @@ } }, "node_modules/@vitest/mocker": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.1.tgz", - "integrity": "sha512-LNN5VwOEdJqCmJ/2XJBywB11DLlkbY0ooDJW3uRX5cZyYCrc4PI/ePX0iQhE3BiEGiQmK4GE7Q/PqCkkaiPnrA==", + "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": { @@ -2614,7 +2634,7 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/spy": "2.1.1", + "@vitest/spy": "2.1.2", "msw": "^2.3.5", "vite": "^5.0.0" }, @@ -2628,9 +2648,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz", - "integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==", + "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": { @@ -2641,13 +2661,13 @@ } }, "node_modules/@vitest/runner": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.1.tgz", - "integrity": "sha512-uTPuY6PWOYitIkLPidaY5L3t0JJITdGTSwBtwMjKzo5O6RCOEncz9PUN+0pDidX8kTHYjO0EwUIvhlGpnGpxmA==", + "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.1.1", + "@vitest/utils": "2.1.2", "pathe": "^1.1.2" }, "funding": { @@ -2655,13 +2675,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.1.tgz", - "integrity": "sha512-BnSku1WFy7r4mm96ha2FzN99AZJgpZOWrAhtQfoxjUU5YMRpq1zmHRq7a5K9/NjqonebO7iVDla+VvZS8BOWMw==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "magic-string": "^0.30.11", "pathe": "^1.1.2" }, @@ -2670,9 +2690,9 @@ } }, "node_modules/@vitest/spy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.1.tgz", - "integrity": "sha512-ZM39BnZ9t/xZ/nF4UwRH5il0Sw93QnZXd9NAZGRpIgj0yvVwPpLd702s/Cx955rGaMlyBQkZJ2Ir7qyY48VZ+g==", + "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": { @@ -2683,13 +2703,13 @@ } }, "node_modules/@vitest/utils": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.1.tgz", - "integrity": "sha512-Y6Q9TsI+qJ2CC0ZKj6VBb+T8UPz593N113nnUykqwANqhgf3QkZeHFlusgKLTqrnVHbj/XDKZcDHol+dxVT+rQ==", + "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.1.1", + "@vitest/pretty-format": "2.1.2", "loupe": "^3.1.1", "tinyrainbow": "^1.2.0" }, @@ -3372,6 +3392,13 @@ "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", @@ -3533,10 +3560,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", @@ -3839,9 +3867,9 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.44.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.44.0.tgz", - "integrity": "sha512-wav4MOs02vBb1WjvTCYItwJCxMkuk2Z4p+K/eyjL0N/z7ahXLP+0LtQQjiKc2ezuif7GnZLbD1F3o1VHzSvdVg==", + "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": { @@ -4428,16 +4456,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, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -4499,9 +4517,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": { @@ -4527,6 +4545,13 @@ "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", @@ -5250,14 +5275,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, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-queue": { "version": "0.1.0", @@ -5320,6 +5342,78 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -7085,9 +7179,9 @@ } }, "node_modules/svelte-check": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.3.tgz", - "integrity": "sha512-V2eqOEuNrPi1jGf307opR1JZ+ITP6/7R8ALKSw4Uw3NWp6GfA+fe7tYtEvZc7QHCavYKBizCK4JFwYjbuPCeXQ==", + "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": { @@ -7155,9 +7249,9 @@ } }, "node_modules/svelte-check/node_modules/readdirp": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz", - "integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==", + "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": { @@ -7636,9 +7730,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", @@ -8255,9 +8349,9 @@ } }, "node_modules/vite-node": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.1.tgz", - "integrity": "sha512-N/mGckI1suG/5wQI35XeR9rsMsPqKXzq1CdUndzVstBj/HvyxxGctwnK6WX43NGt5L3Z5tcRf83g4TITKJhPrA==", + "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": { @@ -8291,19 +8385,19 @@ } }, "node_modules/vitest": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.1.tgz", - "integrity": "sha512-97We7/VC0e9X5zBVkvt7SGQMGrRtn3KtySFQG5fpaMlS+l62eeXRQO633AYhSTC3z7IMebnPPNjGXVGNRFlxBA==", + "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": { - "@vitest/expect": "2.1.1", - "@vitest/mocker": "2.1.1", - "@vitest/pretty-format": "^2.1.1", - "@vitest/runner": "2.1.1", - "@vitest/snapshot": "2.1.1", - "@vitest/spy": "2.1.1", - "@vitest/utils": "2.1.1", + "@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.6", "magic-string": "^0.30.11", @@ -8314,7 +8408,7 @@ "tinypool": "^1.0.0", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", - "vite-node": "2.1.1", + "vite-node": "2.1.2", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8329,8 +8423,8 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.1", - "@vitest/ui": "2.1.1", + "@vitest/browser": "2.1.2", + "@vitest/ui": "2.1.2", "happy-dom": "*", "jsdom": "*" }, diff --git a/web/package.json b/web/package.json index 20553759fad43a..79abff645f27e2 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.2", + "version": "1.118.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", @@ -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.3.0", + "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@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 d76e52c8593f41..6fd02dc9f811b0 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -13,8 +13,8 @@ - - + + %sveltekit.head%