diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index b2ba244ab9..6a9a01d41d 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -225,6 +225,13 @@ jobs: github_token: ${{ steps.token.outputs.token }} working_directory: ./mobile + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: '3.3' + bundler-cache: true + working-directory: ./mobile/ios + - name: Install dependencies run: mise //mobile:install:ci @@ -235,18 +242,6 @@ jobs: working-directory: ./mobile run: flutter build ios --config-only --no-codesign - - name: Setup Ruby - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 - with: - ruby-version: '3.3' - bundler-cache: true - working-directory: ./mobile/ios - - - name: Install CocoaPods dependencies - working-directory: ./mobile/ios - run: | - pod install - - name: Create API Key env: API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 522444db97..3f5491ce96 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@b7c3adeb54330db1903d27c61db520e5661ad55b # v0.1.11 + uses: oasdiff/oasdiff-action/breaking@033c15c845bef10f148afb0fa781bf1b2a7fe1bf # v0.1.12 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 522de204ea..36063f8c49 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -71,7 +71,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -84,6 +84,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/fix-format.yml b/.github/workflows/fix-format.yml index 2638c31fca..5b35793116 100644 --- a/.github/workflows/fix-format.yml +++ b/.github/workflows/fix-format.yml @@ -38,7 +38,7 @@ jobs: run: pnpm --recursive install && pnpm run --recursive --if-present --parallel format:fix - name: Commit and push - uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0 + uses: EndBug/add-and-commit@645ecc0dd0a57f4d86d26c0aa5fc42c0a856fbca # v11.0.0 with: default_author: github_actions message: 'chore: fix formatting' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index abc84c7cdb..22a0d2494c 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -70,7 +70,7 @@ jobs: # TODO move to mise - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 - name: Bump version env: @@ -88,7 +88,7 @@ jobs: - name: Commit and tag id: push-tag - uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 # v10.0.0 + uses: EndBug/add-and-commit@645ecc0dd0a57f4d86d26c0aa5fc42c0a856fbca # v11.0.0 with: default_author: github_actions message: 'chore: version ${{ steps.output.outputs.version }}' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb3448e870..8a4ecceb14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -734,7 +734,7 @@ jobs: github_token: ${{ steps.token.outputs.token }} - name: Install server dependencies - run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile + run: pnpm --filter immich install --frozen-lockfile - name: Run API generation run: mise //:open-api working-directory: open-api @@ -792,7 +792,7 @@ jobs: github_token: ${{ steps.token.outputs.token }} - name: Install server dependencies - run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Build plugins run: mise //:plugins diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a4a820ec70..3f807a1f17 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -156,7 +156,7 @@ services: container_name: immich_redis image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 database: container_name: immich_postgres diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index b717de02f9..c071ae282b 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -58,7 +58,7 @@ services: container_name: immich_redis image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 restart: always database: @@ -97,7 +97,7 @@ services: command: ['./run.sh', '-disable-reporting'] ports: - 3000:3000 - image: grafana/grafana:12.4.7-ubuntu@sha256:d327f509400334fbea74a282d287f20c3187222741ec99e76f253cac16c90129 + image: grafana/grafana:12.4.8-ubuntu@sha256:473cfc1712694135a5d9dc012dc573f2a48e51efb9ac0da50418918f3cbd8ad3 volumes: - grafana-data:/var/lib/grafana diff --git a/docker/docker-compose.rootless.yml b/docker/docker-compose.rootless.yml index 65b13f2b9d..d92d6669bd 100644 --- a/docker/docker-compose.rootless.yml +++ b/docker/docker-compose.rootless.yml @@ -68,7 +68,7 @@ services: volumes: - ./redis:/data healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 restart: always database: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4bcf52ca6e..b13add0998 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -51,7 +51,7 @@ services: container_name: immich_redis image: docker.io/valkey/valkey:9@sha256:3acc0687f2a2e1091fae6450d7842dd658c941338cf0a873ddd9e14b9e4ea4dd healthcheck: - test: redis-cli ping || exit 1 + test: redis-cli ping | grep -q PONG || exit 1 restart: always database: diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 246b1be7fe..06fdda6997 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -154,9 +154,10 @@ identity_providers: - client_id: 'immich' client_name: 'Immich' # https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-a-client-identifier-or-client-secret - client_secret: $pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' + client_secret: '$pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng' public: false - require_pkce: false + require_pkce: true + pkce_challenge_method: 'S256' redirect_uris: - 'https://example.immich.app/auth/login' - 'https://example.immich.app/user-settings' @@ -180,7 +181,7 @@ Configuration of OAuth in Immich System Settings | Setting | Value | | ---------------------------------- | ------------------------------------------------------------------- | -| Issuer URL | `https://example.immich.app/.well-known/openid-configuration` | +| Issuer URL | `https://auth.example.com` | | Client ID | immich | | Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... | | Token Endpoint Auth Method | client_secret_post | @@ -212,21 +213,21 @@ Configuration of Authorised redirect URIs (Authentik OAuth2/OpenID Provider) Configuration of OAuth in Immich System Settings -| Setting | Value | -| ---------------------------- | ---------------------------------------------------------------------------------- | -| Issuer URL | `https://example.immich.app/application/o/immich/.well-known/openid-configuration` | -| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... | -| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... | -| Scope | openid email profile | -| Signing Algorithm | RS256 | -| Storage Label Claim | preferred_username | -| Storage Quota Claim | immich_quota | -| Default Storage Quota (GiB) | 0 (empty for unlimited quota) | -| Button Text | Sign in with Authentik (optional) | -| Auto Register | Enabled (optional) | -| Auto Launch | Enabled (optional) | -| Mobile Redirect URI Override | Disable | -| Mobile Redirect URI | | +| Setting | Value | +| ---------------------------- | ------------------------------------------------------------------- | +| Issuer URL | `https://authentik.example.com/application/o/immich/` | +| Client ID | AFCj2rM1f4rps**\*\*\*\***\***\*\*\*\***lCLEum6hH9... | +| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... | +| Scope | openid email profile | +| Signing Algorithm | RS256 | +| Storage Label Claim | preferred_username | +| Storage Quota Claim | immich_quota | +| Default Storage Quota (GiB) | 0 (empty for unlimited quota) | +| Button Text | Sign in with Authentik (optional) | +| Auto Register | Enabled (optional) | +| Auto Launch | Enabled (optional) | +| Mobile Redirect URI Override | Disable | +| Mobile Redirect URI | | diff --git a/docs/docs/features/command-line-interface.md b/docs/docs/features/command-line-interface.md index c3eab1605f..965db81b07 100644 --- a/docs/docs/features/command-line-interface.md +++ b/docs/docs/features/command-line-interface.md @@ -15,7 +15,7 @@ If you are looking to import your Google Photos takeout, we recommend this commu ## Requirements -- Node.js 20 or above +- Node.js 22 or above - Npm If you can't install node/npm, there is also a Docker version available below. diff --git a/docs/docs/guides/template-backup-script.md b/docs/docs/guides/template-backup-script.md index 19647d4ae1..dfec17a99a 100644 --- a/docs/docs/guides/template-backup-script.md +++ b/docs/docs/guides/template-backup-script.md @@ -52,9 +52,9 @@ REMOTE_BACKUP_PATH="/path/to/remote/backup/directory" ### Local # Backup Immich database -docker exec -t immich_postgres pg_dumpall --clean --if-exists --username= > "$UPLOAD_LOCATION"/database-backup/immich-database.sql +docker exec -t immich_postgres pg_dump --clean --if-exists --dbname --username= > "$UPLOAD_LOCATION"/database-backup/immich-database.sql # For deduplicating backup programs such as Borg or Restic, compressing the content can increase backup size by making it harder to deduplicate. If you are using a different program or still prefer to compress, you can use the following command instead: -# docker exec -t immich_postgres pg_dumpall --clean --if-exists --username= | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz +# docker exec -t immich_postgres pg_dump --clean --if-exists --dbname --username= | /usr/bin/gzip --rsyncable > "$UPLOAD_LOCATION"/database-backup/immich-database.sql.gz ### Append to local Borg repository borg create "$BACKUP_PATH/immich-borg::{now}" "$UPLOAD_LOCATION" --exclude "$UPLOAD_LOCATION"/thumbs/ --exclude "$UPLOAD_LOCATION"/encoded-video/ diff --git a/docs/mise.toml b/docs/mise.toml index da5d00940e..f376ed624a 100644 --- a/docs/mise.toml +++ b/docs/mise.toml @@ -28,4 +28,4 @@ run = "prettier --write ." run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}" [tools] -wrangler = "4.119.0" +wrangler = "4.122.0" diff --git a/docs/src/components/version-switcher.tsx b/docs/src/components/version-switcher.tsx index 739d7bd001..967c85c780 100644 --- a/docs/src/components/version-switcher.tsx +++ b/docs/src/components/version-switcher.tsx @@ -2,6 +2,8 @@ import { useWindowSize } from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import React, { useEffect, useState } from 'react'; +const ARCHIVED_VERSIONS_URL = 'https://version.immich.cloud/v1/docs/versions'; + export default function VersionSwitcher(): JSX.Element { const [versions, setVersions] = useState([]); const [activeLabel, setLabel] = useState('Versions'); @@ -11,12 +13,7 @@ export default function VersionSwitcher(): JSX.Element { useEffect(() => { async function getVersions() { try { - let baseUrl = 'https://docs.immich.app'; - if (window.location.origin === 'http://localhost:3005') { - baseUrl = window.location.origin; - } - - const response = await fetch(`${baseUrl}/archived-versions.json`); + const response = await fetch(ARCHIVED_VERSIONS_URL); const archiveVersions = await response.json(); diff --git a/docs/static/_redirects b/docs/static/_redirects index 633025ac17..e218124e59 100644 --- a/docs/static/_redirects +++ b/docs/static/_redirects @@ -1,3 +1,4 @@ +/archived-versions.json https://version.immich.cloud/v1/docs/versions 307 / /overview/quick-start 307 /mobile-app-beta-program /features/mobile-app 307 /contribution-guidelines /overview/support-the-project#contributing 307 diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json deleted file mode 100644 index dbeed76b73..0000000000 --- a/docs/static/archived-versions.json +++ /dev/null @@ -1,265 +0,0 @@ -[ - { - "label": "v3.1.0", - "url": "https://docs.v3.1.0.archive.immich.app" - }, - { - "label": "v3.0.3", - "url": "https://docs.v3.0.3.archive.immich.app" - }, - { - "label": "v2.7.5", - "url": "https://docs.v2.7.5.archive.immich.app" - }, - { - "label": "v2.6.3", - "url": "https://docs.v2.6.3.archive.immich.app" - }, - { - "label": "v2.5.6", - "url": "https://docs.v2.5.6.archive.immich.app" - }, - { - "label": "v2.4.1", - "url": "https://docs.v2.4.1.archive.immich.app" - }, - { - "label": "v2.3.1", - "url": "https://docs.v2.3.1.archive.immich.app" - }, - { - "label": "v2.2.3", - "url": "https://docs.v2.2.3.archive.immich.app" - }, - { - "label": "v2.1.0", - "url": "https://docs.v2.1.0.archive.immich.app" - }, - { - "label": "v2.0.1", - "url": "https://docs.v2.0.1.archive.immich.app" - }, - { - "label": "v1.144.1", - "url": "https://docs.v1.144.1.archive.immich.app" - }, - { - "label": "v1.143.1", - "url": "https://docs.v1.143.1.archive.immich.app" - }, - { - "label": "v1.142.1", - "url": "https://v1.142.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.141.1", - "url": "https://v1.141.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.140.1", - "url": "https://v1.140.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.139.4", - "url": "https://v1.139.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.138.1", - "url": "https://v1.138.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.137.3", - "url": "https://v1.137.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.136.0", - "url": "https://v1.136.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.135.3", - "url": "https://v1.135.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.134.0", - "url": "https://v1.134.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.133.1", - "url": "https://v1.133.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.132.3", - "url": "https://v1.132.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.131.3", - "url": "https://v1.131.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.130.3", - "url": "https://v1.130.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.129.0", - "url": "https://v1.129.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.128.0", - "url": "https://v1.128.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.127.0", - "url": "https://v1.127.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.126.1", - "url": "https://v1.126.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.125.7", - "url": "https://v1.125.7.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.124.2", - "url": "https://v1.124.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.123.0", - "url": "https://v1.123.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.122.3", - "url": "https://v1.122.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.121.0", - "url": "https://v1.121.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.120.2", - "url": "https://v1.120.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.119.1", - "url": "https://v1.119.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.118.2", - "url": "https://v1.118.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.117.0", - "url": "https://v1.117.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.116.2", - "url": "https://v1.116.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.115.0", - "url": "https://v1.115.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.114.0", - "url": "https://v1.114.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.113.1", - "url": "https://v1.113.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.112.1", - "url": "https://v1.112.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.111.0", - "url": "https://v1.111.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.110.0", - "url": "https://v1.110.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.109.2", - "url": "https://v1.109.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.108.0", - "url": "https://v1.108.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.107.2", - "url": "https://v1.107.2.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.106.4", - "url": "https://v1.106.4.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.105.1", - "url": "https://v1.105.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.104.0", - "url": "https://v1.104.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.103.1", - "url": "https://v1.103.1.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.102.3", - "url": "https://v1.102.3.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.101.0", - "url": "https://v1.101.0.archive.immich.app", - "rootPath": "/docs" - }, - { - "label": "v1.100.0", - "url": "https://v1.100.0.archive.immich.app", - "rootPath": "/docs" - } -] diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 7c03cea653..dd7f52e0f3 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -16,6 +16,7 @@ services: context: ../ dockerfile: server/Dockerfile cache_from: + # hash is the sha256 of the build-args in docker.yml - type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-amd64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main - type=registry,ref=ghcr.io/immich-app/immich-server-build-cache:linux-arm64-cc099f297acd18c924b35ece3245215b53d106eb2518e3af6415931d055746cd-main args: diff --git a/e2e/eslint.config.mjs b/e2e/eslint.config.mjs index d5da8f8445..1e57910b01 100644 --- a/e2e/eslint.config.mjs +++ b/e2e/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, @@ -50,6 +45,7 @@ export default typescriptEslint.config([ 'unicorn/prefer-promise-with-resolvers': 'off', 'unicorn/no-declarations-before-early-exit': 'off', 'unicorn/prefer-simple-condition-first': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], curly: 2, 'prettier/prettier': 0, 'unicorn/name-replacements': 'off', diff --git a/e2e/package.json b/e2e/package.json index 6f38758a5f..135e6c5f16 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -40,7 +40,7 @@ "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^72.0.0", + "eslint-plugin-unicorn": "^73.0.0", "exiftool-vendored": "^35.0.0", "globals": "^17.0.0", "luxon": "^3.4.4", @@ -48,7 +48,7 @@ "pngjs": "^7.0.0", "prettier": "^3.7.4", "prettier-plugin-organize-imports": "^4.0.0", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "socket.io-client": "^4.7.4", "supertest": "^7.0.0", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/e2e/src/api/specs/duplicate.e2e-spec.ts b/e2e/src/api/specs/duplicate.e2e-spec.ts deleted file mode 100644 index d6d0ec1394..0000000000 --- a/e2e/src/api/specs/duplicate.e2e-spec.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { LoginResponseDto } from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -describe('/duplicates', () => { - let admin: LoginResponseDto; - let user1: LoginResponseDto; - let user2: LoginResponseDto; - - beforeAll(async () => { - await utils.resetDatabase(); - - admin = await utils.adminSetup(); - - [user1, user2] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - ]); - }); - - beforeEach(async () => { - // Reset assets, albums, tags, and stacks between tests to ensure clean state for repeated test runs - // Note: We don't reset users since they're set up once in beforeAll - // Stack must be reset before asset due to foreign key constraint - await utils.resetDatabase(['stack', 'asset', 'album', 'tag']); - }); - - describe('GET /duplicates', () => { - it('should return empty array when no duplicates', async () => { - const { status, body } = await request(app) - .get('/duplicates') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - it('should return duplicate groups with suggestedKeepAssetIds', async () => { - // Create assets with different file sizes for duplicate detection - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Manually set duplicateId on both assets to create a duplicate group - const duplicateId = '00000000-0000-4000-8000-000000000001'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .get('/duplicates') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([ - { - duplicateId, - assets: expect.arrayContaining([ - expect.objectContaining({ id: asset1.id }), - expect.objectContaining({ id: asset2.id }), - ]), - suggestedKeepAssetIds: expect.any(Array), - }, - ]); - expect(body[0].suggestedKeepAssetIds.length).toBe(1); - }); - }); - - describe('POST /duplicates/resolve', () => { - it('should require authentication', async () => { - const { status, body } = await request(app) - .post('/duplicates/resolve') - .send({ - groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }], - }); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should return failure for non-existent duplicate group', async () => { - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId: uuidDto.dummy, keepAssetIds: [], trashAssetIds: [] }], - }); - - expect(status).toBe(200); - expect(body).toEqual({ - status: 'COMPLETED', - results: [ - { - duplicateId: uuidDto.dummy, - status: 'FAILED', - reason: expect.stringContaining('not found or access denied'), - }, - ], - }); - }); - - it('should resolve duplicate group with keepers', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000002'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body).toEqual({ - status: 'COMPLETED', - results: [ - { - duplicateId, - status: 'SUCCESS', - }, - ], - }); - - // Verify side effects: duplicateId cleared on kept asset - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.duplicateId).toBeNull(); - - // Verify side effects: trashed asset is trashed and duplicateId cleared - const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id); - expect(trashedAsset.isTrashed).toBe(true); - expect(trashedAsset.duplicateId).toBeNull(); - }); - - it('should reject when keepAssetIds and trashAssetIds overlap', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000003'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('disjoint'); - }); - - it('should require keepAssetIds when partially trashing', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000004'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('must cover all assets'); - }); - - it('should reject partial resolution (not all assets covered)', async () => { - const [asset1, asset2, asset3] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000010'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset3.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('must cover all assets'); - }); - - it('should reject asset not in duplicate group', async () => { - const [asset1, asset2, outsideAsset] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000011'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsideAsset.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('not a member of duplicate group'); - }); - - it('should allow trash-all without keepers', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000012'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }], - }); - - expect(status).toBe(200); - expect(body).toEqual({ - status: 'COMPLETED', - results: [ - { - duplicateId, - status: 'SUCCESS', - }, - ], - }); - - // Verify both assets are trashed - const [asset1Info, asset2Info] = await Promise.all([ - utils.getAssetInfo(user1.accessToken, asset1.id), - utils.getAssetInfo(user1.accessToken, asset2.id), - ]); - - expect(asset1Info.isTrashed).toBe(true); - expect(asset1Info.duplicateId).toBeNull(); - expect(asset2Info.isTrashed).toBe(true); - expect(asset2Info.duplicateId).toBeNull(); - }); - - it('should reject cross-user duplicate group access', async () => { - const asset1 = await utils.createAsset(user1.accessToken); - const asset2 = await utils.createAsset(user2.accessToken); - - const duplicateId = '00000000-0000-4000-8000-000000000013'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user2.accessToken, asset2.id, duplicateId); - - // User1 tries to resolve a group containing user2's asset - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('FAILED'); - expect(body.results[0].reason).toContain('not a member of duplicate group'); - }); - - it('should synchronize favorites when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Mark one asset as favorite - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], isFavorite: true }); - - const duplicateId = '00000000-0000-4000-8000-000000000020'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify favorite was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.isFavorite).toBe(true); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize visibility when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Archive one asset - await utils.archiveAssets(user1.accessToken, [asset2.id]); - - const duplicateId = '00000000-0000-4000-8000-000000000021'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify visibility was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.visibility).toBe('archive'); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize rating when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Set rating on one asset - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], rating: 5 }); - - const duplicateId = '00000000-0000-4000-8000-000000000022'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify rating was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.exifInfo?.rating).toBe(5); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize description when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Set description on one asset - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], description: 'Test description for duplicate' }); - - const duplicateId = '00000000-0000-4000-8000-000000000023'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify description was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.exifInfo?.description).toBe('Test description for duplicate'); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize location when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Set location on one asset - await request(app) - .put('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [asset2.id], latitude: 40.7128, longitude: -74.006 }); - - const duplicateId = '00000000-0000-4000-8000-000000000024'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify location was synchronized to keeper - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.exifInfo?.latitude).toBe(40.7128); - expect(keptAsset.exifInfo?.longitude).toBe(-74.006); - expect(keptAsset.duplicateId).toBeNull(); - }); - - it('should synchronize albums when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Create albums and add assets to different albums - const album1 = await utils.createAlbum(user1.accessToken, { - albumName: 'Album 1', - assetIds: [asset1.id], - }); - const album2 = await utils.createAlbum(user1.accessToken, { - albumName: 'Album 2', - assetIds: [asset2.id], - }); - - const duplicateId = '00000000-0000-4000-8000-000000000025'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify keeper is now in both albums - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.duplicateId).toBeNull(); - - // Check albums directly - const { status: album1Status, body: album1Body } = await request(app) - .get(`/albums/${album1.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - const { status: album2Status, body: album2Body } = await request(app) - .get(`/albums/${album2.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(album1Status).toBe(200); - expect(album2Status).toBe(200); - expect(album1Body.assets.map((a: any) => a.id)).toContain(asset1.id); - expect(album2Body.assets.map((a: any) => a.id)).toContain(asset1.id); - }); - - it('should synchronize tags when enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - // Wait for metadata extraction to complete before adding tags - // Otherwise, metadata jobs will race and overwrite our tags - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - - // Create tags and tag assets differently - const tags = await utils.upsertTags(user1.accessToken, ['tag1', 'tag2']); - await utils.tagAssets(user1.accessToken, tags[0].id, [asset1.id]); - await utils.tagAssets(user1.accessToken, tags[1].id, [asset2.id]); - - const duplicateId = '00000000-0000-4000-8000-000000000026'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify keeper has both tags - const keptAsset = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(keptAsset.duplicateId).toBeNull(); - expect(keptAsset.tags).toBeDefined(); - const tagIds = keptAsset.tags?.map((t) => t.id) || []; - expect(tagIds).toContain(tags[0].id); - expect(tagIds).toContain(tags[1].id); - }); - - it('should handle batch resolve with mixed success and failure', async () => { - // Create first group that will succeed - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - const duplicateId1 = '00000000-0000-4000-8000-000000000027'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId1); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId1); - - // Create second group with non-existent duplicate ID (will fail) - const fakeId = '00000000-0000-4000-8000-000000000099'; - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [ - { duplicateId: duplicateId1, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }, - { duplicateId: fakeId, keepAssetIds: [], trashAssetIds: [] }, - ], - }); - - expect(status).toBe(200); - expect(body.status).toBe('COMPLETED'); - expect(body.results).toHaveLength(2); - - // First group should succeed - expect(body.results[0].duplicateId).toBe(duplicateId1); - expect(body.results[0].status).toBe('SUCCESS'); - - // Second group should fail - expect(body.results[1].duplicateId).toBe(fakeId); - expect(body.results[1].status).toBe('FAILED'); - expect(body.results[1].reason).toContain('not found or access denied'); - - // Verify first group was actually resolved despite second failure - const asset1Info = await utils.getAssetInfo(user1.accessToken, asset1.id); - expect(asset1Info.duplicateId).toBeNull(); - const asset2Info = await utils.getAssetInfo(user1.accessToken, asset2.id); - expect(asset2Info.isTrashed).toBe(true); - }); - - it('should trash assets when trash is enabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000028'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - // Ensure trash is enabled (default) - const config = await utils.getSystemConfig(admin.accessToken); - expect(config.trash.enabled).toBe(true); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Verify asset is trashed (not deleted) - const trashedAsset = await utils.getAssetInfo(user1.accessToken, asset2.id); - expect(trashedAsset.isTrashed).toBe(true); - }); - - it('should delete assets when trash is disabled', async () => { - const [asset1, asset2] = await Promise.all([ - utils.createAsset(user1.accessToken), - utils.createAsset(user1.accessToken), - ]); - - const duplicateId = '00000000-0000-4000-8000-000000000029'; - await utils.setAssetDuplicateId(user1.accessToken, asset1.id, duplicateId); - await utils.setAssetDuplicateId(user1.accessToken, asset2.id, duplicateId); - - // Disable trash - await request(app) - .put('/system-config') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - trash: { enabled: false, days: 30 }, - }); - - const { status, body } = await request(app) - .post('/duplicates/resolve') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ - groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }], - }); - - expect(status).toBe(200); - expect(body.results[0].status).toBe('SUCCESS'); - - // Asset should be marked as deleted (force delete) - const { status: getStatus } = await request(app) - .get(`/assets/${asset2.id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - // Asset should still be accessible (soft deleted) but marked as deleted - expect(getStatus).toBe(200); - - // Re-enable trash for other tests - await utils.resetAdminConfig(admin.accessToken); - }); - }); -}); diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 1e03ad6d24..a43e2c3e69 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -1,9 +1,7 @@ export const uuidDto = { - invalid: 'invalid-uuid', // valid uuid v4 notFound: '00000000-0000-4000-a000-000000000000', dummy: '00000000-0000-4000-a000-000000000001', - dummy2: '00000000-0000-4000-a000-000000000002', }; const adminLoginDto = { @@ -57,22 +55,6 @@ export const createUserDto = { }; export const userDto = { - admin: { - name: signupDto.admin.name, - email: signupDto.admin.email, - password: signupDto.admin.password, - storageLabel: 'admin', - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }, user1: { name: createUserDto.user1.name, email: createUserDto.user1.email, diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 1b3447767e..54cfb814e8 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -1,89 +1,7 @@ import { expect } from 'vitest'; export const errorDto = { - unauthorized: { - message: 'Authentication required', - }, - unauthorizedWithMessage: (message: string) => ({ - message, - }), - forbidden: { - message: expect.any(String), - }, - missingPermission: (permission: string) => ({ - message: `Missing required permission: ${permission}`, - }), - wrongPassword: { - message: 'Wrong password', - }, - invalidToken: { - message: 'Invalid user token', - }, - invalidShareKey: { - message: 'Invalid share key', - }, - passwordRequired: { - message: 'Password required', - }, badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), - validationError: (errors?: ReadonlyArray<{ path: ReadonlyArray; message: string }>) => ({ - message: 'Validation failed', - errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), - }), - noPermission: { - message: expect.stringContaining('Not found or no'), - }, - incorrectLogin: { - message: 'Incorrect email or password', - }, -}; - -export const signupResponseDto = { - admin: { - avatarColor: expect.any(String), - id: expect.any(String), - name: 'Immich Admin', - email: 'admin@immich.cloud', - storageLabel: 'admin', - profileImagePath: '', - // why? lol - shouldChangePassword: true, - isAdmin: true, - createdAt: expect.any(String), - updatedAt: expect.any(String), - deletedAt: null, - oauthId: '', - quotaUsageInBytes: 0, - quotaSizeInBytes: null, - status: 'active', - license: null, - profileChangedAt: expect.any(String), - }, -}; - -export const loginResponseDto = { - admin: { - accessToken: expect.any(String), - name: 'Immich Admin', - isAdmin: true, - isOnboarded: false, - profileImagePath: '', - shouldChangePassword: true, - userEmail: 'admin@immich.cloud', - userId: expect.any(String), - }, -}; -export const deviceDto = { - current: { - id: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - current: true, - isPendingSyncReset: false, - deviceOS: '', - deviceType: '', - appVersion: null, - }, }; diff --git a/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts index f3833ffc00..bea5f58557 100644 --- a/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/maintenance.e2e-spec.ts @@ -1,5 +1,4 @@ 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'; @@ -8,18 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/admin/maintenance', () => { let cookie: string | undefined; let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); await utils.resetBackups(admin.accessToken); }); // => outside of maintenance mode - describe('GET ~/server/config', async () => { + describe('GET /server/config', async () => { it('should indicate we are out of maintenance mode', async () => { const { status, body } = await request(app).get('/server/config'); expect(status).toBe(200); @@ -49,24 +46,6 @@ describe('/admin/maintenance', () => { // => enter maintenance mode describe.sequential('POST /', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/admin/maintenance').send({ - active: false, - action: 'end', - }); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should only work for admins', async () => { - const { status, body } = await request(app) - .post('/admin/maintenance') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ action: 'end' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should be a no-op if try to exit maintenance mode', async () => { const { status } = await request(app) .post('/admin/maintenance') @@ -132,7 +111,7 @@ describe('/admin/maintenance', () => { it('should fail without cookie or token in body', async () => { const { status, body } = await request(app).post('/admin/maintenance/login').send({}); expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token')); + expect(body).toEqual({ message: 'Missing JWT Token' }); }); it('should succeed with cookie', async () => { diff --git a/e2e/src/specs/server/api/activity.e2e-spec.ts b/e2e/src/specs/server/api/activity.e2e-spec.ts deleted file mode 100644 index 9ce9b4b916..0000000000 --- a/e2e/src/specs/server/api/activity.e2e-spec.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { - ActivityCreateDto, - AlbumResponseDto, - AlbumUserRole, - AssetMediaResponseDto, - LoginResponseDto, - ReactionType, - createActivity as create, - createAlbum, - removeAssetFromAlbum, -} from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, asBearerAuth, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; - -describe('/activities', () => { - let admin: LoginResponseDto; - let nonOwner: LoginResponseDto; - let asset: AssetMediaResponseDto; - let album: AlbumResponseDto; - - const createActivity = (dto: ActivityCreateDto, accessToken?: string) => - create({ activityCreateDto: dto }, { headers: asBearerAuth(accessToken || admin.accessToken) }); - - beforeAll(async () => { - await utils.resetDatabase(); - - admin = await utils.adminSetup(); - nonOwner = await utils.userSetup(admin.accessToken, createUserDto.user1); - asset = await utils.createAsset(admin.accessToken); - album = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 1', - assetIds: [asset.id], - albumUsers: [{ userId: nonOwner.userId, role: AlbumUserRole.Editor }], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - }); - - beforeEach(async () => { - await utils.resetDatabase(['activity']); - }); - - describe('GET /activities', () => { - it('should start off empty', async () => { - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(body).toEqual([]); - expect(status).toEqual(200); - }); - - it('should filter by album id', async () => { - const album2 = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 2', - assetIds: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - const [reaction] = await Promise.all([ - createActivity({ albumId: album.id, type: ReactionType.Like }), - createActivity({ albumId: album2.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by type=comment', async () => { - const [reaction] = await Promise.all([ - createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'comment', - }), - createActivity({ albumId: album.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, type: 'comment' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by type=like', async () => { - const [reaction] = await Promise.all([ - createActivity({ albumId: album.id, type: ReactionType.Like }), - createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'comment', - }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, type: 'like' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - - it('should filter by userId', async () => { - const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); - - const response1 = await request(app) - .get('/activities') - .query({ albumId: album.id, userId: uuidDto.notFound }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(response1.status).toEqual(200); - expect(response1.body.length).toBe(0); - - const response2 = await request(app) - .get('/activities') - .query({ albumId: album.id, userId: admin.userId }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(response2.status).toEqual(200); - expect(response2.body.length).toBe(1); - expect(response2.body[0]).toEqual(reaction); - }); - - it('should filter by assetId', async () => { - const [reaction] = await Promise.all([ - createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }), - createActivity({ albumId: album.id, type: ReactionType.Like }), - ]); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id, assetId: asset.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body.length).toBe(1); - expect(body[0]).toEqual(reaction); - }); - }); - - describe('POST /activities', () => { - it('should add a comment to an album', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - albumId: album.id, - type: 'comment', - comment: 'This is my first comment', - }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: null, - createdAt: expect.any(String), - type: 'comment', - comment: 'This is my first comment', - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should add a like to an album', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: null, - createdAt: expect.any(String), - type: 'like', - comment: null, - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should return a 200 for a duplicate like on the album', async () => { - const reaction = await createActivity({ albumId: album.id, type: ReactionType.Like }); - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(200); - expect(body).toEqual(reaction); - }); - - it('should not confuse an album like with an asset like', async () => { - const reaction = await createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }); - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, type: 'like' }); - expect(status).toEqual(201); - expect(body.id).not.toEqual(reaction.id); - }); - - it('should add a comment to an asset', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - albumId: album.id, - assetId: asset.id, - type: 'comment', - comment: 'This is my first comment', - }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: asset.id, - createdAt: expect.any(String), - type: 'comment', - comment: 'This is my first comment', - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should add a like to an asset', async () => { - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, assetId: asset.id, type: 'like' }); - expect(status).toEqual(201); - expect(body).toEqual({ - id: expect.any(String), - assetId: asset.id, - createdAt: expect.any(String), - type: 'like', - comment: null, - user: expect.objectContaining({ email: admin.userEmail }), - }); - }); - - it('should return a 200 for a duplicate like on an asset', async () => { - const reaction = await createActivity({ - albumId: album.id, - assetId: asset.id, - type: ReactionType.Like, - }); - - const { status, body } = await request(app) - .post('/activities') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ albumId: album.id, assetId: asset.id, type: 'like' }); - expect(status).toEqual(200); - expect(body).toEqual(reaction); - }); - }); - - describe('DELETE /activities/:id', () => { - it('should remove a comment from an album', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(204); - }); - - it('should remove a like from an album', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Like, - }); - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(204); - }); - - it('should let the owner remove a comment by another user', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toEqual(204); - }); - - it('should not let a user remove a comment by another user', async () => { - const reaction = await createActivity({ - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }); - - const { status, body } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${nonOwner.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access')); - }); - - it('should let a non-owner remove their own comment', async () => { - const reaction = await createActivity( - { - albumId: album.id, - type: ReactionType.Comment, - comment: 'This is a test comment', - }, - nonOwner.accessToken, - ); - - const { status } = await request(app) - .delete(`/activities/${reaction.id}`) - .set('Authorization', `Bearer ${nonOwner.accessToken}`); - - expect(status).toBe(204); - }); - - it('should return empty list when asset is removed', async () => { - const album3 = await createAlbum( - { - createAlbumDto: { - albumName: 'Album 3', - assetIds: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - await createActivity({ albumId: album3.id, assetId: asset.id, type: ReactionType.Like }); - - await removeAssetFromAlbum( - { - id: album3.id, - bulkIdsDto: { - ids: [asset.id], - }, - }, - { headers: asBearerAuth(admin.accessToken) }, - ); - - const { status, body } = await request(app) - .get('/activities') - .query({ albumId: album.id }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toEqual(200); - expect(body).toEqual([]); - }); - }); -}); diff --git a/e2e/src/specs/server/api/album.e2e-spec.ts b/e2e/src/specs/server/api/album.e2e-spec.ts index f252e5d321..2969d606fe 100644 --- a/e2e/src/specs/server/api/album.e2e-spec.ts +++ b/e2e/src/specs/server/api/album.e2e-spec.ts @@ -613,16 +613,6 @@ describe('/albums', () => { }); describe('DELETE /albums/:id/assets', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .delete(`/albums/${user1Albums[1].id}/assets`) - .set('Authorization', `Bearer ${user2.accessToken}`) - .send({ ids: [user1Asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should be able to remove foreign asset from owned album', async () => { const { status, body } = await request(app) .delete(`/albums/${user2Albums[0].id}/assets`) diff --git a/e2e/src/specs/server/api/api-key.e2e-spec.ts b/e2e/src/specs/server/api/api-key.e2e-spec.ts index 28d134a664..ec481cee01 100644 --- a/e2e/src/specs/server/api/api-key.e2e-spec.ts +++ b/e2e/src/specs/server/api/api-key.e2e-spec.ts @@ -24,13 +24,6 @@ describe('/api-keys', () => { }); describe('POST /api-keys', () => { - it('should not work without permission', async () => { - const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]); - const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('apiKey.create')); - }); - it('should work with apiKey.create', async () => { const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]); const { status, body } = await request(app) @@ -41,14 +34,14 @@ describe('/api-keys', () => { permissions: [Permission.ApiKeyRead], }); expect(body).toEqual({ + id: expect.any(String), + name: 'API Key', + permissions: [Permission.ApiKeyRead], + createdAt: expect.any(String), + updatedAt: expect.any(String), secret: expect.any(String), - apiKey: { - id: expect.any(String), - name: 'API Key', - permissions: [Permission.ApiKeyRead], - createdAt: expect.any(String), - updatedAt: expect.any(String), - }, + // TODO: remove in v4 + apiKey: expect.any(Object), }); expect(status).toBe(201); }); @@ -79,14 +72,14 @@ describe('/api-keys', () => { .send({ name: 'API Key', permissions: [Permission.All] }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual({ - apiKey: { - id: expect.any(String), - name: 'API Key', - permissions: [Permission.All], - createdAt: expect.any(String), - updatedAt: expect.any(String), - }, + id: expect.any(String), + name: 'API Key', + permissions: [Permission.All], + createdAt: expect.any(String), + updatedAt: expect.any(String), secret: expect.any(String), + // TODO: remove in v4 + apiKey: expect.any(Object), }); expect(status).toEqual(201); }); @@ -100,32 +93,30 @@ describe('/api-keys', () => { }); it('should return a list of api keys', async () => { - const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([ + const [apiKey1, apiKey2, apiKey3] = await Promise.all([ create(admin.accessToken, [Permission.All]), create(admin.accessToken, [Permission.All]), create(admin.accessToken, [Permission.All]), ]); + const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toHaveLength(3); - expect(body).toEqual(expect.arrayContaining([apiKey1, apiKey2, apiKey3])); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: apiKey1.id }), + expect.objectContaining({ id: apiKey2.id }), + expect.objectContaining({ id: apiKey3.id }), + ]), + ); expect(status).toEqual(200); }); }); describe('GET /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .get(`/api-keys/${apiKey.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should get api key details', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) - .get(`/api-keys/${apiKey.id}`) + .get(`/api-keys/${id}`) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(200); expect(body).toEqual({ @@ -139,20 +130,10 @@ describe('/api-keys', () => { }); describe('PUT /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .put(`/api-keys/${apiKey.id}`) - .send({ name: 'new name', permissions: [Permission.All] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should update api key details', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status, body } = await request(app) - .put(`/api-keys/${apiKey.id}`) + .put(`/api-keys/${id}`) .send({ name: 'new name', permissions: [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate], @@ -170,19 +151,10 @@ describe('/api-keys', () => { }); describe('DELETE /api-keys/:id', () => { - it('should require authorization', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); - const { status, body } = await request(app) - .delete(`/api-keys/${apiKey.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest('API Key not found')); - }); - it('should delete an api key', async () => { - const { apiKey } = await create(user.accessToken, [Permission.All]); + const { id } = await create(user.accessToken, [Permission.All]); const { status } = await request(app) - .delete(`/api-keys/${apiKey.id}`) + .delete(`/api-keys/${id}`) .set('Authorization', `Bearer ${user.accessToken}`); expect(status).toBe(204); }); diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index ccebd1ca77..ad7f102810 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -130,7 +130,7 @@ describe('/asset', () => { }); await utils.createFace({ assetId: user1Assets[0].id, - personId: person1.id, + personGroupId: person1.id, }); }; beforeAll(setupTests, 30_000); @@ -151,14 +151,6 @@ describe('/asset', () => { }); describe('GET /assets/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .get(`/assets/${user2Assets[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should get the asset info', async () => { const { status, body } = await request(app) .get(`/assets/${user1Assets[0].id}`) @@ -306,15 +298,6 @@ describe('/asset', () => { }); describe('PUT /assets/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/assets/${user2Assets[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({}); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should favorite an asset', async () => { const before = await utils.getAssetInfo(user1.accessToken, user1Assets[0].id); expect(before.isFavorite).toBe(false); diff --git a/e2e/src/specs/server/api/jobs.e2e-spec.ts b/e2e/src/specs/server/api/jobs.e2e-spec.ts index be7984404b..b4228eeb91 100644 --- a/e2e/src/specs/server/api/jobs.e2e-spec.ts +++ b/e2e/src/specs/server/api/jobs.e2e-spec.ts @@ -2,9 +2,7 @@ import { LoginResponseDto, QueueCommand, QueueName, updateConfig } from '@immich import { cpSync, rmSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { basename } from 'node:path'; -import { errorDto } from 'src/responses'; -import { app, asBearerAuth, testAssetDir, utils } from 'src/utils'; -import request from 'supertest'; +import { asBearerAuth, testAssetDir, utils } from 'src/utils'; import { afterEach, beforeAll, describe, expect, it } from 'vitest'; describe('/jobs', () => { @@ -47,13 +45,7 @@ describe('/jobs', () => { config.machineLearning.enabled = false; config.metadata.faces.import = false; config.machineLearning.clip.enabled = false; - await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); - }); - - it('should require authentication', async () => { - const { status, body } = await request(app).put('/jobs/metadataExtraction'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); + await updateConfig({ adminConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); }); it('should queue metadata extraction for missing assets', async () => { diff --git a/e2e/src/specs/server/api/library.e2e-spec.ts b/e2e/src/specs/server/api/library.e2e-spec.ts index ccb594610c..c3b83cf4e4 100644 --- a/e2e/src/specs/server/api/library.e2e-spec.ts +++ b/e2e/src/specs/server/api/library.e2e-spec.ts @@ -1,28 +1,20 @@ -import { LibraryResponseDto, LoginResponseDto, getAllLibraries } from '@immich/sdk'; -import { cpSync, existsSync, rmSync, unlinkSync } from 'node:fs'; +import { LoginResponseDto } from '@immich/sdk'; +import { cpSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { userDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; -import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; -import request from 'supertest'; +import { testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import { utimes } from 'utimes'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/libraries', () => { let admin: LoginResponseDto; - let user: LoginResponseDto; - let library: LibraryResponseDto; let websocket: Socket; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); await utils.resetAdminConfig(admin.accessToken); - user = await utils.userSetup(admin.accessToken, userDto.user1); - library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); websocket = await utils.connectWebsocket(admin.accessToken); utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`); - utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`); }); afterAll(() => { @@ -34,292 +26,7 @@ describe('/libraries', () => { utils.resetEvents(); }); - describe('GET /libraries', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/libraries'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - }); - - describe('POST /libraries', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/libraries').send({}); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require admin authentication', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${user.accessToken}`) - .send({ ownerId: admin.userId }); - - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - - it('should create an external library with defaults', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ownerId: admin.userId }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - ownerId: admin.userId, - name: 'New External Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: expect.any(Array), - }), - ); - }); - - it('should create an external library with options', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - exclusionPatterns: ['**/Raw/**'], - }); - - expect(status).toBe(201); - expect(body).toEqual( - expect.objectContaining({ - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - }), - ); - }); - - it('should not create an external library with duplicate import paths', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path', '/path'], - exclusionPatterns: ['**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), - ); - }); - - it('should not create an external library with duplicate exclusion patterns', async () => { - const { status, body } = await request(app) - .post('/libraries') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ - ownerId: admin.userId, - name: 'My Awesome Library', - importPaths: ['/path/to/import'], - exclusionPatterns: ['**/Raw/**', '**/Raw/**'], - }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), - ); - }); - }); - - describe('PUT /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/libraries/${uuidDto.notFound}`).send({}); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should change the library name', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ name: 'New Library Name' }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - name: 'New Library Name', - }), - ); - }); - - it('should not set an empty name', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ name: '' }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['name'], message: 'Too small: expected string to have >=1 characters' }]), - ); - }); - - it('should change the import paths', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [testAssetDirInternal] }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - importPaths: [testAssetDirInternal], - }), - ); - }); - - it('should reject an empty import path', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: [''] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), - ); - }); - - it('should reject duplicate import paths', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ importPaths: ['/path', '/path'] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), - ); - }); - - it('should change the exclusion pattern', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/Raw/**'] }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - exclusionPatterns: ['**/Raw/**'], - }), - ); - }); - - it('should reject duplicate exclusion patterns', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), - ); - }); - - it('should reject an empty exclusion pattern', async () => { - const { status, body } = await request(app) - .put(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ exclusionPatterns: [''] }); - - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), - ); - }); - }); - - describe('GET /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should require admin access', async () => { - const { status, body } = await request(app) - .get(`/libraries/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - - it('should get library by id', async () => { - const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); - - const { status, body } = await request(app) - .get(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual( - expect.objectContaining({ - ownerId: admin.userId, - name: 'New External Library', - refreshedAt: null, - assetCount: 0, - importPaths: [], - exclusionPatterns: expect.any(Array), - }), - ); - }); - }); - - describe('GET /libraries/:id/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/libraries/${uuidDto.notFound}/statistics`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - }); - describe('POST /libraries/:id/scan', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/scan`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should import new asset when scanning external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/directoryA`], - }); - - 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'); - await utils.waitForQueueFinish(admin.accessToken, 'sidecar'); - await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); - - const { assets } = await utils.searchAssets(admin.accessToken, { - originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`, - libraryId: library.id, - }); - expect(assets.count).toBe(1); - }); - it('should process metadata and thumbnails for external asset', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -339,337 +46,6 @@ describe('/libraries', () => { expect(asset.thumbhash).not.toBe(null); }); - it('should scan external library with exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - exclusionPatterns: ['**/directoryA/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - }); - - it('should scan external library with multiple exclusion patterns', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(0); - - expect(assets.items).toEqual([]); - }); - - it('should remove assets covered by a new exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryA/assetA.png') }), - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { - exclusionPatterns: ['**/directoryA/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining('directoryB/assetB.png') }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { - exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(0); - - expect(assets.items).toEqual([]); - } - }); - - it('should scan multiple import paths', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(2); - expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined(); - 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`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(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`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(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`); - }); - - const annoyingChars = [ - "'", - '"', - '`', - '*', - '{', - '}', - ',', - '(', - ')', - '[', - ']', - '?', - '!', - '@', - '#', - '$', - '%', - '^', - '&', - '=', - '+', - '~', - '|', - '<', - '>', - ';', - ':', - '/', // We never got backslashes to work - ]; - - it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/folder${char}1`, `${testAssetDirInternal}/temp/folder${char}2`], - }); - - utils.createImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}1/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`folder${char}2/asset2.png`) }), - ]), - ); - - utils.removeImageFile(`${testAssetDir}/temp/folder${char}1/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/folder${char}2/asset2.png`); - }); - - it('should respect exclusion patterns when using multiple import paths', async () => { - // https://github.com/immich-app/immich/issues/17121 - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/exclusion/`, `${testAssetDirInternal}/temp/exclusion2/`], - }); - - const excludedFolder = `Raw`; - - utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - }); - - const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; - - it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/exclusion/`], - }); - - const excludedFolder = `${char}folder`; - - utils.createImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.createImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - expect.objectContaining({ originalPath: expect.stringContaining(`${excludedFolder}/asset2.png`) }), - ]), - ); - } - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.items).toEqual([ - expect.objectContaining({ originalPath: expect.stringContaining(`/asset1.png`) }), - ]); - } - - utils.removeImageFile(`${testAssetDir}/temp/exclusion/asset1.png`); - utils.removeImageFile(`${testAssetDir}/temp/exclusion/${excludedFolder}/asset2.png`); - }); - it('should reimport a modified file', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -706,42 +82,6 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); - it('should not reimport a file with unchanged timestamp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/reimport`], - }); - - utils.createImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); - await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, `${testAssetDir}/temp/reimport/asset.jpg`); - await utimes(`${testAssetDir}/temp/reimport/asset.jpg`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { - libraryId: library.id, - }); - - expect(assets.count).toEqual(1); - - const asset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(asset).toEqual( - expect.objectContaining({ - originalFileName: 'asset.jpg', - exifInfo: expect.not.objectContaining({ - model: 'NIKON D750', - }), - }), - ); - - utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); - }); - it('should not reimport a modified file more than once', async () => { const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, @@ -782,681 +122,5 @@ describe('/libraries', () => { utils.removeImageFile(`${testAssetDir}/temp/reimport/asset.jpg`); }); - - it('should set an asset offline if its file is missing', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(trashedAsset.isOffline).toEqual(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(newAssets.items).toEqual([]); - }); - - it('should set an asset offline if 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`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - - utils.createDirectory(`${testAssetDir}/temp/another-path/`); - - await utils.updateLibrary(admin.accessToken, library.id, { - importPaths: [`${testAssetDirInternal}/temp/another-path/`], - }); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(trashedAsset.isOffline).toBe(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([]); - - utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`); - utils.removeDirectory(`${testAssetDir}/temp/another-path/`); - }); - - it('should set an asset offline if its file is covered by an exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { - libraryId: library.id, - originalFileName: 'assetB.png', - }); - expect(assets.count).toBe(1); - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/directoryB/**'] }); - - await utils.scan(admin.accessToken, library.id); - - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(trashedAsset.isTrashed).toBe(true); - expect(trashedAsset.originalPath).toBe(`${testAssetDirInternal}/temp/directoryB/assetB.png`); - expect(trashedAsset.isOffline).toBe(true); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'assetA.png', - }), - ]); - }); - - it('should not set an asset offline if its file exists, is in an import path, and not covered by an exclusion pattern', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assetsBefore.count).toBeGreaterThan(1); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets).toEqual(assetsBefore); - }); - - describe('xmp metadata', async () => { - it('should import metadata from file.xmp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should import metadata from file.ext.xmp', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should import metadata in file.ext.xmp before file.xmp if both exist', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.xmp to file.ext.xmp when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file metadata to file.xmp metadata when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file metadata to file.ext.xmp metadata when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2000-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.ext.xmp to file.xmp when asset refreshes', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2010.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-09-27T12:35:33.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.ext.xmp to file metadata', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.nef.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - unlinkSync(`${testAssetDir}/temp/xmp/glarus.nef.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-07-20T17:27:12.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - - it('should switch from using file.xmp to file metadata', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/xmp`], - }); - - cpSync(`${testAssetDir}/metadata/xmp/dates/2000.xmp`, `${testAssetDir}/temp/xmp/glarus.xmp`); - cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, `${testAssetDir}/temp/xmp/glarus.nef`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_000); - - await utils.scan(admin.accessToken, library.id); - - unlinkSync(`${testAssetDir}/temp/xmp/glarus.xmp`); - await utimes(`${testAssetDir}/temp/xmp/glarus.nef`, 447_775_200_001); - - await utils.scan(admin.accessToken, library.id); - - const { assets: newAssets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(newAssets.items).toEqual([ - expect.objectContaining({ - originalFileName: 'glarus.nef', - fileCreatedAt: '2010-07-20T17:27:12.000Z', - }), - ]); - - rmSync(`${testAssetDir}/temp/xmp`, { recursive: true, force: true }); - }); - }); - - it('should set an offline asset to online if its file exists, is in an import path, and not covered by an exclusion pattern', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(backOnlineAsset.isTrashed).toBe(false); - expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(backOnlineAsset.isOffline).toBe(false); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assets.count).toBe(1); - } - }); - - it('should set a trashed offline asset to online but keep it in trash', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - expect(assets.count).toBe(1); - - await utils.deleteAssets(admin.accessToken, [assets.items[0].id]); - - { - const trashedAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(trashedAsset.isTrashed).toBe(true); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const backOnlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(backOnlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(backOnlineAsset.isOffline).toBe(false); - expect(backOnlineAsset.isTrashed).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - }); - - it('should not set an offline asset to online if its file exists, is not covered by an exclusion pattern, but is outside of all import paths', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - utils.createDirectory(`${testAssetDir}/temp/another-path/`); - - await utils.updateLibrary(admin.accessToken, library.id, { - importPaths: [`${testAssetDirInternal}/temp/another-path`], - }); - - await utils.scan(admin.accessToken, library.id); - - const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(stillOfflineAsset.isTrashed).toBe(true); - expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(stillOfflineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - - utils.removeDirectory(`${testAssetDir}/temp/another-path/`); - }); - - it('should not set an offline asset to online if its file exists, is in an import path, but is covered by an exclusion pattern', async () => { - utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); - - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp/offline`], - }); - - await utils.scan(admin.accessToken, library.id); - - { - const { assets: assetsBefore } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); - expect(assetsBefore.count).toBe(1); - } - - utils.renameImageFile(`${testAssetDir}/temp/offline/offline.png`, `${testAssetDir}/temp/offline.png`); - - await utils.scan(admin.accessToken, library.id); - - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - - const offlineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(offlineAsset.isTrashed).toBe(true); - expect(offlineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(offlineAsset.isOffline).toBe(true); - - utils.renameImageFile(`${testAssetDir}/temp/offline.png`, `${testAssetDir}/temp/offline/offline.png`); - - await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); - - await utils.scan(admin.accessToken, library.id); - - const stillOfflineAsset = await utils.getAssetInfo(admin.accessToken, assets.items[0].id); - - expect(stillOfflineAsset.isTrashed).toBe(true); - expect(stillOfflineAsset.originalPath).toBe(`${testAssetDirInternal}/temp/offline/offline.png`); - expect(stillOfflineAsset.isOffline).toBe(true); - - { - const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id, withDeleted: true }); - expect(assets.count).toBe(1); - } - }); - }); - - describe('POST /libraries/:id/validate', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/libraries/${uuidDto.notFound}/validate`).send({}); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should pass with no import paths', async () => { - const response = await utils.validateLibrary(admin.accessToken, library.id, { importPaths: [] }); - expect(response.importPaths).toEqual([]); - }); - - it('should fail if path does not exist', async () => { - const pathToTest = `${testAssetDirInternal}/does/not/exist`; - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Path does not exist (ENOENT)`, - }); - }); - - it("should fail if path isn't absolute", async () => { - const pathToTest = `relative/path`; - - const cwd = process.cwd(); - // Create directory in cwd - utils.createDirectory(`${cwd}/${pathToTest}`); - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - utils.removeDirectory(`${cwd}/${pathToTest}`); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: expect.stringMatching('Import path must be absolute, try /usr/src/app/relative/path'), - }); - }); - - it('should fail if path is a file', async () => { - const pathToTest = `${testAssetDirInternal}/albums/nature/el_torcal_rocks.jpg`; - - const response = await utils.validateLibrary(admin.accessToken, library.id, { - importPaths: [pathToTest], - }); - - expect(response.importPaths?.length).toEqual(1); - const pathResponse = response?.importPaths?.at(0); - - expect(pathResponse).toEqual({ - importPath: pathToTest, - isValid: false, - message: `Not a directory`, - }); - }); - }); - - describe('DELETE /libraries/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).delete(`/libraries/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should delete an external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId }); - - const { status, body } = await request(app) - .delete(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(204); - expect(body).toEqual({}); - - const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); - expect(libraries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: library.id, - }), - ]), - ); - }); - - it('should delete an external library with assets', async () => { - const library = await utils.createLibrary(admin.accessToken, { - ownerId: admin.userId, - importPaths: [`${testAssetDirInternal}/temp`], - }); - - await utils.scan(admin.accessToken, library.id); - - const { status, body } = await request(app) - .delete(`/libraries/${library.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(204); - expect(body).toEqual({}); - - const libraries = await getAllLibraries({ headers: asBearerAuth(admin.accessToken) }); - expect(libraries).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: library.id, - }), - ]), - ); - - // ensure no files get deleted - expect(existsSync(`${testAssetDir}/temp/directoryA/assetA.png`)).toBe(true); - expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true); - }); }); }); diff --git a/e2e/src/specs/server/api/map.e2e-spec.ts b/e2e/src/specs/server/api/map.e2e-spec.ts index c177fa740b..c7c504b3c5 100644 --- a/e2e/src/specs/server/api/map.e2e-spec.ts +++ b/e2e/src/specs/server/api/map.e2e-spec.ts @@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -55,12 +54,6 @@ describe('/map', () => { }); describe('GET /map/markers', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/markers'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should get map markers for all non-archived assets', async () => { const { status, body } = await request(app) .get('/map/markers') @@ -139,52 +132,6 @@ describe('/map', () => { }); describe('GET /map/reverse-geocode', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/map/reverse-geocode'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should throw an error if a lat is not provided', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lon=123') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - - it('should throw an error if a lat is not a number', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=abc&lon=123.456') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - - it('should throw an error if a lat is out of range', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=91&lon=123.456') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), - ); - }); - - it('should throw an error if a lon is not provided', async () => { - const { status, body } = await request(app) - .get('/map/reverse-geocode?lat=75') - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), - ); - }); - const reverseGeocodeTestCases = [ { name: 'Vaucluse', diff --git a/e2e/src/specs/server/api/memory.e2e-spec.ts b/e2e/src/specs/server/api/memory.e2e-spec.ts index e5e2351738..38c6ba875a 100644 --- a/e2e/src/specs/server/api/memory.e2e-spec.ts +++ b/e2e/src/specs/server/api/memory.e2e-spec.ts @@ -7,7 +7,6 @@ import { getMemory, } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -42,14 +41,6 @@ describe('/memories', () => { }); describe('GET /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .get(`/memories/${userMemory.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should get the memory', async () => { const { status, body } = await request(app) .get(`/memories/${userMemory.id}`) @@ -60,15 +51,6 @@ describe('/memories', () => { }); describe('PUT /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}`) - .send({ isSaved: true }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should update the memory', async () => { const before = await getMemory({ id: userMemory.id }, { headers: asBearerAuth(user.accessToken) }); expect(before.isSaved).toBe(false); @@ -86,15 +68,6 @@ describe('/memories', () => { }); describe('PUT /memories/:id/assets', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .put(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should require asset access', async () => { const { status, body } = await request(app) .put(`/memories/${userMemory.id}/assets`) @@ -121,15 +94,6 @@ describe('/memories', () => { }); describe('DELETE /memories/:id/assets', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}/assets`) - .send({ ids: [userAsset1.id] }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should only remove assets in the memory', async () => { const { status, body } = await request(app) .delete(`/memories/${userMemory.id}/assets`) @@ -156,14 +120,6 @@ describe('/memories', () => { }); describe('DELETE /memories/:id', () => { - it('should require access', async () => { - const { status, body } = await request(app) - .delete(`/memories/${userMemory.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should delete the memory', async () => { const { status } = await request(app) .delete(`/memories/${userMemory.id}`) diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index ade6475519..5bb569d5d2 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -1,7 +1,7 @@ import { OAuthClient, OAuthUser, generateLogoutToken } from '@immich/e2e-auth-server'; import { + AdminConfigOAuthDto, LoginResponseDto, - SystemConfigOAuthDto, getConfigDefaults, getMyUser, getSessions, @@ -70,17 +70,18 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) => return { url: redirectUrl, state, codeVerifier }; }; -const setupOAuth = async (token: string, dto: Partial) => { +const setupOAuth = async (token: string, dto: Partial) => { const options = { headers: asBearerAuth(token) }; const defaults = await getConfigDefaults(options); const merged = { ...defaults.oauth, buttonText: 'Login with Immich', issuerUrl: `${authServer.internal}/.well-known/openid-configuration`, + accountManagementUrl: authServer.internal, allowInsecureRequests: true, ...dto, }; - await updateConfig({ systemConfigDto: { ...defaults, oauth: merged } }, options); + await updateConfig({ adminConfigDto: { ...defaults, oauth: merged } }, options); }; describe(`/oauth`, () => { @@ -102,16 +103,6 @@ describe(`/oauth`, () => { }); }); - it(`should throw an error if a redirect uri is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/authorize').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' }, - ]), - ); - }); - it('should return a redirect uri', async () => { const { status, body } = await request(app) .post('/oauth/authorize') @@ -165,22 +156,6 @@ describe(`/oauth`, () => { }); }); - it(`should throw an error if a url is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/callback').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['url'], message: 'Invalid input: expected string, received undefined' }]), - ); - }); - - it(`should throw an error if the url is empty`, async () => { - const { status, body } = await request(app).post('/oauth/callback').send({ url: '' }); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([{ path: ['url'], message: 'Too small: expected string to have >=1 characters' }]), - ); - }); - it(`should throw an error if the state is not provided`, async () => { const { url } = await loginWithOAuth('oauth-auto-register'); const { status, body } = await request(app).post('/oauth/callback').send({ url }); @@ -380,16 +355,6 @@ describe(`/oauth`, () => { }); describe(`POST /oauth/backchannel-logout`, () => { - it(`should throw an error if the logout_token is not provided`, async () => { - const { status, body } = await request(app).post('/oauth/backchannel-logout').send({}); - expect(status).toBe(400); - expect(body).toEqual( - errorDto.validationError([ - { path: ['logout_token'], message: 'Invalid input: expected string, received undefined' }, - ]), - ); - }); - it(`should throw an error if an invalid logout token is provided`, async () => { const { status, body } = await request(app) .post('/oauth/backchannel-logout') diff --git a/e2e/src/specs/server/api/partner.e2e-spec.ts b/e2e/src/specs/server/api/partner.e2e-spec.ts deleted file mode 100644 index 0eb7d6db3d..0000000000 --- a/e2e/src/specs/server/api/partner.e2e-spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { LoginResponseDto, createPartner } from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; -import { app, asBearerAuth, utils } from 'src/utils'; -import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; - -describe('/partners', () => { - let admin: LoginResponseDto; - let user1: LoginResponseDto; - let user2: LoginResponseDto; - let user3: LoginResponseDto; - - beforeAll(async () => { - await utils.resetDatabase(); - - admin = await utils.adminSetup(); - - [user1, user2, user3] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - utils.userSetup(admin.accessToken, createUserDto.user3), - ]); - - await Promise.all([ - createPartner({ partnerCreateDto: { sharedWithId: user2.userId } }, { headers: asBearerAuth(user1.accessToken) }), - createPartner({ partnerCreateDto: { sharedWithId: user1.userId } }, { headers: asBearerAuth(user2.accessToken) }), - ]); - }); - - describe('GET /partners', () => { - it('should get all partners shared by user', async () => { - const { status, body } = await request(app) - .get('/partners') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ direction: 'shared-by' }); - - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user2.userId })]); - }); - - it('should get all partners that share with user', async () => { - const { status, body } = await request(app) - .get('/partners') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ direction: 'shared-with' }); - - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user2.userId })]); - }); - }); - - describe('POST /partners/:id', () => { - it('should share with new partner', async () => { - const { status, body } = await request(app) - .post(`/partners/${user3.userId}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(201); - expect(body).toEqual(expect.objectContaining({ id: user3.userId })); - }); - - it('should not share with new partner if already sharing with this partner', async () => { - const { status, body } = await request(app) - .post(`/partners/${user2.userId}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' })); - }); - }); - - describe('PUT /partners/:id', () => { - it('should update partner', async () => { - const { status, body } = await request(app) - .put(`/partners/${user2.userId}`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ inTimeline: false }); - - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false })); - }); - }); - - describe('DELETE /partners/:id', () => { - it('should delete partner', async () => { - const { status } = await request(app) - .delete(`/partners/${user3.userId}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(204); - }); - - it('should throw a bad request if partner not found', async () => { - const { status, body } = await request(app) - .delete(`/partners/${user3.userId}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' })); - }); - }); -}); diff --git a/e2e/src/specs/server/api/person.e2e-spec.ts b/e2e/src/specs/server/api/person.e2e-spec.ts index 20290cd941..7f5518862f 100644 --- a/e2e/src/specs/server/api/person.e2e-spec.ts +++ b/e2e/src/specs/server/api/person.e2e-spec.ts @@ -82,32 +82,32 @@ describe('/people', () => { const asset4 = await utils.createAsset(admin.accessToken); await Promise.all([ - utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }), - utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }), - utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), - utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), - utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }), - utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets + utils.createFace({ assetId: asset1.id, personGroupId: visiblePerson.id }), + utils.createFace({ assetId: asset1.id, personGroupId: hiddenPerson.id }), + utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }), + utils.createFace({ assetId: asset1.id, personGroupId: multipleAssetsPerson.id }), + utils.createFace({ assetId: asset2.id, personGroupId: multipleAssetsPerson.id }), + utils.createFace({ assetId: asset3.id, personGroupId: multipleAssetsPerson.id }), // 4 assets // Named persons - utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset - utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }), - utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets - utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset + utils.createFace({ assetId: asset1.id, personGroupId: nameCharliePerson.id }), // 1 asset + utils.createFace({ assetId: asset1.id, personGroupId: nameBobPerson.id }), + utils.createFace({ assetId: asset2.id, personGroupId: nameBobPerson.id }), // 2 assets + utils.createFace({ assetId: asset1.id, personGroupId: nameAlicePerson.id }), // 1 asset // Null-named person 4 assets - utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }), - utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }), - utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }), - utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets + utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson4Assets.id }), + utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson4Assets.id }), + utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson4Assets.id }), + utils.createFace({ assetId: asset4.id, personGroupId: nameNullPerson4Assets.id }), // 4 assets // Null-named person 3 assets - utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }), - utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }), - utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets + utils.createFace({ assetId: asset1.id, personGroupId: nameNullPerson3Assets.id }), + utils.createFace({ assetId: asset2.id, personGroupId: nameNullPerson3Assets.id }), + utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson3Assets.id }), // 3 assets // Null-named person 1 asset - utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }), + utils.createFace({ assetId: asset3.id, personGroupId: nameNullPerson1Asset.id }), // Favourite People - utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }), - utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }), - utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }), + utils.createFace({ assetId: asset1.id, personGroupId: nameFreddyPersonFavourite.id }), + utils.createFace({ assetId: asset2.id, personGroupId: nameFreddyPersonFavourite.id }), + utils.createFace({ assetId: asset1.id, personGroupId: nameBillPersonFavourite.id }), ]); }); diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index f0eed82f7a..ea410186c9 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -1,6 +1,5 @@ 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'; @@ -122,6 +121,7 @@ describe('/server', () => { expect(body).toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', + oauthAccountManagementUrl: '', trashDays: 30, userDeleteDelay: 7, isInitialized: true, @@ -137,14 +137,6 @@ describe('/server', () => { }); describe('GET /server/statistics', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server/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/statistics') @@ -195,14 +187,6 @@ describe('/server', () => { }); describe('GET /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should return the server license', async () => { await request(app).put('/server/license').set('Authorization', `Bearer ${admin.accessToken}`).send(serverLicense); const { status, body } = await request(app) @@ -217,14 +201,6 @@ describe('/server', () => { }); describe('DELETE /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .delete('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should delete the server license', async () => { await request(app) .delete('/server/license') @@ -236,14 +212,6 @@ describe('/server', () => { }); describe('PUT /server/license', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .put('/server/license') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should set the server license', async () => { const { status, body } = await request(app) .put('/server/license') diff --git a/e2e/src/specs/server/api/session.e2e-spec.ts b/e2e/src/specs/server/api/session.e2e-spec.ts index 1766b732cd..6f1599c65c 100644 --- a/e2e/src/specs/server/api/session.e2e-spec.ts +++ b/e2e/src/specs/server/api/session.e2e-spec.ts @@ -1,6 +1,6 @@ import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk'; import { loginDto, signupDto, uuidDto } from 'src/fixtures'; -import { deviceDto, errorDto } from 'src/responses'; +import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -18,7 +18,18 @@ describe('/sessions', () => { it('should get a list of authorized devices', async () => { const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual([deviceDto.current]); + expect(body).toEqual([ + { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + current: true, + isPendingSyncReset: false, + deviceOS: '', + deviceType: '', + appVersion: null, + }, + ]); }); }); @@ -56,7 +67,7 @@ describe('/sessions', () => { const response = await request(app) .post('/auth/validateToken') .set('Authorization', `Bearer ${admin.accessToken}`); - expect(response.body).toEqual(errorDto.invalidToken); + expect(response.body).toEqual({ message: 'Invalid user token' }); expect(response.status).toBe(401); }); }); diff --git a/e2e/src/specs/server/api/shared-link.e2e-spec.ts b/e2e/src/specs/server/api/shared-link.e2e-spec.ts index b9f695b48e..4e5988a167 100644 --- a/e2e/src/specs/server/api/shared-link.e2e-spec.ts +++ b/e2e/src/specs/server/api/shared-link.e2e-spec.ts @@ -212,21 +212,21 @@ describe('/shared-links', () => { .query({ key: linkWithAlbum.key + 'foo' }); expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidShareKey); + expect(body).toEqual({ message: 'Invalid share key' }); }); it('should return unauthorized if target has been soft deleted', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithDeletedAlbum.key }); expect(status).toBe(401); - expect(body).toEqual(errorDto.invalidShareKey); + expect(body).toEqual({ message: 'Invalid share key' }); }); it('should return unauthorized for password protected link', async () => { const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key }); expect(status).toBe(401); - expect(body).toEqual(errorDto.passwordRequired); + expect(body).toEqual({ message: 'Password required' }); }); it('should get data for correct password protected link', async () => { @@ -306,24 +306,14 @@ describe('/shared-links', () => { }); describe('POST /shared-links', () => { - it('should require an asset/album id', async () => { - const { status, body } = await request(app) - .post('/shared-links') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ type: SharedLinkType.Album }); - - expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' })); - }); - it('should require a valid asset id', async () => { const { status, body } = await request(app) .post('/shared-links') .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ type: SharedLinkType.Individual, assetId: uuidDto.notFound }); + .send({ type: SharedLinkType.Individual, assetIds: [uuidDto.notFound] }); expect(status).toBe(400); - expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' })); + expect(body).toEqual(expect.objectContaining({ message: 'Not found or no asset.share access' })); }); it('should create a shared link', async () => { @@ -403,16 +393,6 @@ describe('/shared-links', () => { expect(body).toEqual(errorDto.badRequest('Invalid shared link type')); }); - it('should reject guests removing assets from an individual shared link', async () => { - const { status, body } = await request(app) - .delete(`/shared-links/${linkWithAssets.id}/assets`) - .query({ key: linkWithAssets.key }) - .send({ assetIds: [asset1.id] }); - - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should remove assets from a shared link (individual)', async () => { const { status, body } = await request(app) .delete(`/shared-links/${linkWithAssets.id}/assets`) diff --git a/e2e/src/specs/server/api/stack.e2e-spec.ts b/e2e/src/specs/server/api/stack.e2e-spec.ts index 3b8f642334..21284dcf78 100644 --- a/e2e/src/specs/server/api/stack.e2e-spec.ts +++ b/e2e/src/specs/server/api/stack.e2e-spec.ts @@ -1,6 +1,5 @@ -import { AssetMediaResponseDto, LoginResponseDto, searchStacks } from '@immich/sdk'; +import { LoginResponseDto, searchStacks } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -8,34 +7,16 @@ import { beforeAll, describe, expect, it } from 'vitest'; describe('/stacks', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; - let user2: LoginResponseDto; - let asset: AssetMediaResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - [user1, user2] = await Promise.all([ - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - ]); - - asset = await utils.createAsset(user1.accessToken); + user1 = await utils.userSetup(admin.accessToken, createUserDto.user1); }); describe('POST /stacks', () => { - it('should require access', async () => { - const user2Asset = await utils.createAsset(user2.accessToken); - const { status, body } = await request(app) - .post('/stacks') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ assetIds: [asset.id, user2Asset.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - it('should create a stack', async () => { const [asset1, asset2] = await Promise.all([ utils.createAsset(user1.accessToken), diff --git a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts index 114ed2f995..18589ed1d7 100644 --- a/e2e/src/specs/server/api/system-metadata.e2e-spec.ts +++ b/e2e/src/specs/server/api/system-metadata.e2e-spec.ts @@ -1,30 +1,17 @@ import { LoginResponseDto, getServerConfig } 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('POST /system-metadata/admin-onboarding', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .post('/system-metadata/admin-onboarding') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send({ isOnboarded: true }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should set admin onboarding', async () => { const config = await getServerConfig({}); expect(config.isOnboarded).toBe(false); @@ -41,14 +28,6 @@ describe('/server-info', () => { }); describe('GET /system-metadata/reverse-geocoding-state', () => { - it('should only work for admins', async () => { - const { status, body } = await request(app) - .get('/system-metadata/reverse-geocoding-state') - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should get the reverse geocoding state', async () => { const { status, body } = await request(app) .get('/system-metadata/reverse-geocoding-state') diff --git a/e2e/src/specs/server/api/tag.e2e-spec.ts b/e2e/src/specs/server/api/tag.e2e-spec.ts index 98d66f6350..40c8cd5114 100644 --- a/e2e/src/specs/server/api/tag.e2e-spec.ts +++ b/e2e/src/specs/server/api/tag.e2e-spec.ts @@ -9,7 +9,7 @@ import { tagAssets, upsertTags, } from '@immich/sdk'; -import { createUserDto, uuidDto } from 'src/fixtures'; +import { createUserDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; @@ -41,13 +41,6 @@ describe('/tags', () => { }); describe('POST /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.create')); - }); - it('should work with tag.create', async () => { const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]); const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); @@ -111,13 +104,6 @@ describe('/tags', () => { }); describe('GET /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).get('/tags').set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.read')); - }); - it('should start off empty', async () => { const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toEqual([]); @@ -157,13 +143,6 @@ describe('/tags', () => { }); describe('PUT /tags', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.create')); - }); - it('should upsert tags', async () => { const { status, body } = await request(app) .put(`/tags`) @@ -195,16 +174,6 @@ describe('/tags', () => { }); describe('PUT /tags/assets', () => { - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put('/tags/assets') - .set('x-api-key', secret) - .send({ assetIds: [], tagIds: [] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should skip assets that are not owned by the user', async () => { const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ create(user.accessToken, { name: 'TagA' }), @@ -255,25 +224,6 @@ describe('/tags', () => { }); describe('GET /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .get(`/tags/${tag.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .get(`/tags/${uuidDto.notFound}`) - .set('x-api-key', secret) - .send({ assetIds: [], tagIds: [] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.read')); - }); - it('should get tag details', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -311,27 +261,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(admin.accessToken, { name: 'tagA' }); - const { status, body } = await request(app) - .put(`/tags/${tag.id}`) - .send({ color: '#000000' }) - .set('Authorization', `Bearer ${user.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put(`/tags/${tag.id}`) - .set('x-api-key', secret) - .send({ color: '#000000' }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.update')); - }); - it('should update a tag', async () => { const tag = await create(user.accessToken, { name: 'tagA' }); const { status, body } = await request(app) @@ -354,23 +283,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .delete(`/tags/${tag.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.delete')); - }); - it('should delete a tag', async () => { const tag = await create(user.accessToken, { name: 'TagA' }); const { status } = await request(app) @@ -404,27 +316,6 @@ describe('/tags', () => { }); describe('PUT /tags/:id/assets', () => { - it('should require authorization', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { status, body } = await request(app) - .put(`/tags/${tag.id}/assets`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ids: [userAsset.id] }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .put(`/tags/${tag.id}/assets`) - .set('x-api-key', secret) - .send({ ids: [userAsset.id] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should be able to tag own asset', async () => { const tagA = await create(user.accessToken, { name: 'TagA' }); const { status, body } = await request(app) @@ -463,32 +354,6 @@ describe('/tags', () => { }); describe('DELETE /tags/:id/assets', () => { - it('should require authorization', async () => { - const tagA = await create(user.accessToken, { name: 'TagA' }); - await tagAssets( - { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, - { headers: asBearerAuth(user.accessToken) }, - ); - const { status, body } = await request(app) - .delete(`/tags/${tagA.id}/assets`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ ids: [userAsset.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorDto.noPermission); - }); - - it('should require authorization (api key)', async () => { - const tag = await create(user.accessToken, { name: 'TagA' }); - const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); - const { status, body } = await request(app) - .delete(`/tags/${tag.id}/assets`) - .set('x-api-key', secret) - .send({ ids: [userAsset.id] }); - expect(status).toBe(403); - expect(body).toEqual(errorDto.missingPermission('tag.asset')); - }); - it('should be able to remove own asset from own tag', async () => { const tagA = await create(user.accessToken, { name: 'TagA' }); await tagAssets( diff --git a/e2e/src/specs/server/api/trash.e2e-spec.ts b/e2e/src/specs/server/api/trash.e2e-spec.ts index 7a1a61f946..e3ea72fdd2 100644 --- a/e2e/src/specs/server/api/trash.e2e-spec.ts +++ b/e2e/src/specs/server/api/trash.e2e-spec.ts @@ -1,7 +1,6 @@ import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk'; import { existsSync } from 'node:fs'; import { Socket } from 'socket.io-client'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -21,13 +20,6 @@ describe('/trash', () => { }); describe('POST /trash/empty', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/empty'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should empty the trash', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); @@ -142,13 +134,6 @@ describe('/trash', () => { }); describe('POST /trash/restore', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/restore'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should restore all trashed assets', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); @@ -198,13 +183,6 @@ describe('/trash', () => { }); describe('POST /trash/restore/assets', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post('/trash/restore/assets'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should restore a trashed asset by id', async () => { const { id: assetId } = await utils.createAsset(admin.accessToken); await utils.deleteAssets(admin.accessToken, [assetId]); diff --git a/e2e/src/specs/server/api/user-admin.e2e-spec.ts b/e2e/src/specs/server/api/user-admin.e2e-spec.ts index 160c929591..37f3f2ea99 100644 --- a/e2e/src/specs/server/api/user-admin.e2e-spec.ts +++ b/e2e/src/specs/server/api/user-admin.e2e-spec.ts @@ -1,6 +1,5 @@ import { LoginResponseDto, - Permission, QueueName, createStack, deleteUserAdmin, @@ -10,8 +9,7 @@ import { login, } from '@immich/sdk'; import { Socket } from 'socket.io-client'; -import { createUserDto, uuidDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; +import { createUserDto } from 'src/fixtures'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -46,14 +44,6 @@ describe('/admin/users', () => { }); describe('GET /admin/users', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .get(`/admin/users`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should hide deleted users by default', async () => { const { status, body } = await request(app) .get(`/admin/users`) @@ -88,15 +78,6 @@ describe('/admin/users', () => { }); describe('POST /admin/users', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .post(`/admin/users`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`) - .send(createUserDto.user1); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should accept `isAdmin`', async () => { const { status, body } = await request(app) .post(`/admin/users`) @@ -117,14 +98,6 @@ describe('/admin/users', () => { }); describe('PUT /admin/users/:id', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .put(`/admin/users/${uuidDto.notFound}`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should allow a non-admin to become an admin', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('admin2')); const { status, body } = await request(app) @@ -225,14 +198,6 @@ describe('/admin/users', () => { }); describe('DELETE /admin/users/:id', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .delete(`/admin/users/${userToDelete.userId}`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should delete user', async () => { const { status, body } = await request(app) .delete(`/admin/users/${userToDelete.userId}`) @@ -296,34 +261,7 @@ describe('/admin/users', () => { }); }); - describe('GET /admin/users/:id/calendar-heatmap', () => { - it('should require admin permissions', async () => { - const { status, body } = await request(app) - .get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - - it('should require the AdminUserRead permission', async () => { - const { secret } = await utils.createApiKey(admin.accessToken, [Permission.UserRead]); - const { status, body } = await request(app) - .get(`/admin/users/${nonAdmin.userId}/calendar-heatmap`) - .set('x-api-key', secret); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - }); - describe('POST /admin/users/:id/restore', () => { - it('should require authorization', async () => { - const { status, body } = await request(app) - .post(`/admin/users/${userToDelete.userId}/restore`) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - it('should restore a user', async () => { const user = await utils.userSetup(admin.accessToken, createUserDto.create('restore')); diff --git a/e2e/src/specs/server/api/user.e2e-spec.ts b/e2e/src/specs/server/api/user.e2e-spec.ts index 0e62672f36..ce6236eabc 100644 --- a/e2e/src/specs/server/api/user.e2e-spec.ts +++ b/e2e/src/specs/server/api/user.e2e-spec.ts @@ -1,6 +1,5 @@ -import { LoginResponseDto, SharedLinkType, getMyUser, login } from '@immich/sdk'; +import { LoginResponseDto, getMyUser, login } from '@immich/sdk'; import { createUserDto } from 'src/fixtures'; -import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -15,19 +14,6 @@ describe('/users', () => { nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user2); }); - describe('GET /users/me', () => { - it('should not work for shared links', async () => { - const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' }); - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Album, - albumId: album.id, - }); - const { status, body } = await request(app).get(`/users/me?key=${sharedLink.key}`); - expect(status).toBe(403); - expect(body).toEqual(errorDto.forbidden); - }); - }); - describe('PUT /users/me', () => { /** @deprecated */ it('should allow a user to change their password (deprecated)', async () => { diff --git a/e2e/src/specs/server/cli/login.e2e-spec.ts b/e2e/src/specs/server/cli/login.e2e-spec.ts index caf5550b6e..98a28be448 100644 --- a/e2e/src/specs/server/cli/login.e2e-spec.ts +++ b/e2e/src/specs/server/cli/login.e2e-spec.ts @@ -30,8 +30,8 @@ describe(`immich login`, () => { it('should login and save auth.yml with 600', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken, [Permission.All]); - const { stdout, stderr, exitCode } = await immichCli(['login', app, key.secret]); + const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]); + const { stdout, stderr, exitCode } = await immichCli(['login', app, apiKey.secret]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2285/api', 'Logged in as admin@immich.cloud', @@ -47,8 +47,8 @@ describe(`immich login`, () => { it('should login without /api in the url', async () => { const admin = await utils.adminSetup(); - const key = await utils.createApiKey(admin.accessToken, [Permission.All]); - const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), key.secret]); + const apiKey = await utils.createApiKey(admin.accessToken, [Permission.All]); + const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), apiKey.secret]); expect(stdout.split('\n')).toEqual([ 'Logging in to http://127.0.0.1:2285', 'Discovered API at http://127.0.0.1:2285/api', diff --git a/e2e/src/ui/generators/timeline/timeline-config.ts b/e2e/src/ui/generators/timeline/timeline-config.ts index a3b59bb8c8..5bfb2c1800 100644 --- a/e2e/src/ui/generators/timeline/timeline-config.ts +++ b/e2e/src/ui/generators/timeline/timeline-config.ts @@ -24,13 +24,6 @@ export const ASPECT_RATIO_WEIGHTS = { '3:1': 0.01, // 1% 3:1 panorama } as const; -export type AspectRatio = { - width: number; - height: number; - ratio: number; - name: string; -}; - // Mock configuration for asset generation - will be transformed to API response formats export type MockTimelineAsset = { id: string; diff --git a/e2e/src/ui/specs/timeline/utils.ts b/e2e/src/ui/specs/timeline/utils.ts index dad60266b9..986f794fb6 100644 --- a/e2e/src/ui/specs/timeline/utils.ts +++ b/e2e/src/ui/specs/timeline/utils.ts @@ -1,4 +1,4 @@ -import { BrowserContext, expect, Page } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; import { DateTime } from 'luxon'; import { TimelineAssetConfig } from 'src/ui/generators/timeline'; @@ -11,18 +11,6 @@ export const padYearMonth = (yearMonth: string) => { return `${year}-${month.padStart(2, '0')}`; }; -export async function throttlePage(context: BrowserContext, page: Page) { - const session = await context.newCDPSession(page); - await session.send('Network.emulateNetworkConditions', { - offline: false, - downloadThroughput: (1.5 * 1024 * 1024) / 8, - uploadThroughput: (750 * 1024) / 8, - latency: 40, - connectionType: 'cellular3g', - }); - await session.send('Emulation.setCPUThrottlingRate', { rate: 10 }); -} - export const poll = async ( page: Page, query: () => Promise, diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3124dd0609..131297bb89 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -184,6 +184,8 @@ export const utils = { 'library', 'shared_link', 'person', + 'person_group', + 'cluster_group', 'album', 'asset', 'asset_face', @@ -434,12 +436,12 @@ export const utils = { return person; }, - createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => { + createFace: async ({ assetId, personGroupId }: { assetId: string; personGroupId: string }) => { if (!client) { return; } - await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]); + await client.query('INSERT INTO asset_face ("assetId", "personGroupId") VALUES ($1, $2)', [assetId, personGroupId]); }, setPersonThumbnail: async (personId: string) => { @@ -447,7 +449,9 @@ export const utils = { return; } - await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`, [personId]); + await client.query(`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "personGroupId" = $1`, [ + personId, + ]); }, createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) => @@ -647,7 +651,7 @@ export const utils = { resetAdminConfig: async (accessToken: string) => { const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) }); - await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); + await updateConfig({ adminConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) }); }, isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => { @@ -675,9 +679,9 @@ export const utils = { }, cliLogin: async (accessToken: string) => { - const key = await utils.createApiKey(accessToken, [Permission.All]); - await immichCli(['login', app, key.secret]); - return key.secret; + const { secret } = await utils.createApiKey(accessToken, [Permission.All]); + await immichCli(['login', app, secret]); + return secret; }, scan: async (accessToken: string, id: string) => { diff --git a/i18n/en.json b/i18n/en.json index 536b7677ac..32be9d6c78 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1,5 +1,6 @@ { "about": "About", + "accept": "Accept", "account": "Account", "account_settings": "Account Settings", "acknowledge": "Acknowledge", @@ -36,6 +37,7 @@ "add_to_bottom_bar": "Add to", "add_upload_to_stack": "Add upload to stack", "add_url": "Add URL", + "add_user": "Add user", "added_to_archive": "Added to archive", "added_to_favorites": "Added to favorites", "added_to_favorites_count": "Added {count, number} to favorites", @@ -292,6 +294,8 @@ "notification_enable_email_notifications": "Enable email notifications", "notification_settings": "Notification Settings", "notification_settings_description": "Manage notification settings, including email", + "oauth_account_management_url": "Account Management URL", + "oauth_account_management_url_description": "Location in the external identity provider where a user can manage their settings or profile.", "oauth_allow_insecure_requests": "Allow insecure requests", "oauth_allow_insecure_requests_description": "WARNING: This disables TLS certificate validation for OAuth requests and may expose you to MITM attacks.", "oauth_auto_launch": "Auto launch", @@ -492,6 +496,7 @@ "admin_password": "Admin Password", "administration": "Administration", "advanced": "Advanced", + "advanced_filters": "Advanced filters", "advanced_settings_clear_image_cache": "Clear Image Cache", "advanced_settings_clear_image_cache_error": "Failed to clear image cache", "advanced_settings_clear_image_cache_success": "Successfully cleared {size}", @@ -733,6 +738,9 @@ "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate import/removal is available only before login", "client_cert_title": "SSL client certificate [EXPERIMENTAL]", "close": "Close", + "cluster_group": "Cluster group", + "cluster_group_description": "People are recognized across the photos of everyone in the group", + "cluster_group_invite_description": "You have been invited to join this cluster group.", "collapse": "Collapse", "collapse_all": "Collapse all", "color": "Color", @@ -830,6 +838,7 @@ "date_time_original": "Date/Time Original", "day": "Day", "days": "Days", + "decline": "Decline", "deduplicate_all": "Deduplicate All", "default_quality_subtitle": "Quality used when tapping share. Long press the share button to choose each time.", "default_share_quality": "Default share quality", @@ -891,7 +900,9 @@ "download_settings_description": "Manage settings related to asset download", "download_waiting_to_retry": "Waiting to retry", "downloading": "Downloading", + "downloading_archive_filename_size": "Downloading archive {filename} (approximately {size})", "downloading_asset_filename": "Downloading asset {filename}", + "downloading_filename": "Downloading {filename}", "downloading_from_icloud": "Downloading from iCloud", "downloading_media": "Downloading media", "drag_to_reorder": "Drag to reorder", @@ -1040,8 +1051,12 @@ "unable_to_get_comments_number": "Unable to get number of comments", "unable_to_get_shared_link": "Failed to get shared link", "unable_to_hide_person": "Unable to hide person", + "unable_to_leave_cluster_group": "Unable to leave cluster group", "unable_to_link_motion_video": "Unable to link motion video", "unable_to_link_oauth_account": "Unable to link OAuth account", + "unable_to_load_cluster_group": "Unable to load cluster group", + "unable_to_load_map": "Unable to load map", + "unable_to_load_map_description": "The map requires WebGL to work properly.", "unable_to_log_out_all_devices": "Unable to log out all devices", "unable_to_log_out_device": "Unable to log out device", "unable_to_login_with_oauth": "Unable to login with OAuth", @@ -1131,6 +1146,7 @@ "filename": "Filename", "filetype": "Filetype", "filter": "Filter", + "filter_by": "Filter by", "filter_people": "Filter people", "filter_places": "Filter places", "filter_tags": "Filter tags", @@ -1259,6 +1275,8 @@ "latitude": "Latitude", "leave": "Leave", "leave_album": "Leave album", + "leave_group": "Leave group", + "leave_group_description": "People will no longer be recognized across the photos of everyone in the group. Are you sure you want to continue?", "lens_model": "Lens model", "less": "Less", "let_others_respond": "Let others respond", @@ -1365,6 +1383,8 @@ "manage_media_access_settings": "Open settings", "manage_media_access_subtitle": "Allow the Immich app to manage and move media files.", "manage_media_access_title": "Media Management Access", + "manage_oauth_account": "Manage OAuth Account", + "manage_sharing_with_other_users": "Manage sharing with other users", "manage_sharing_with_partners": "Manage sharing with partners", "manage_the_app_settings": "Manage the app settings", "manage_your_account": "Manage your account", @@ -1394,6 +1414,7 @@ "marked_all_as_read": "Marked all as read", "matches": "Matches", "matching_assets": "Matching Assets", + "media": "Media", "media_chrome": { "auto": "Auto", "captions": "Captions", @@ -1427,6 +1448,7 @@ "volume": "volume" }, "media_type": "Media type", + "media_type_description": "Show only photos or videos from your library.", "memories": "Memories", "memories_all_caught_up": "All caught up", "memories_check_back_tomorrow": "Check back tomorrow for more memories", @@ -1469,6 +1491,7 @@ "my_immich_title": "My Immich link", "name": "Name", "name_or_nickname": "Name or nickname", + "name_plus_more_people": "{name} + {count, plural, one {# person} other {# people}}", "name_required": "Name is required", "navigate": "Navigate", "navigate_to_time": "Navigate to Time", @@ -1608,8 +1631,10 @@ "paused": "Paused", "pending": "Pending", "people": "People", + "people_count": "{count, plural, one {# person} other {# people}}", "people_edits_count": "Edited {count, plural, one {# person} other {# people}}", "people_feature_description": "Browsing photos and videos grouped by people", + "people_search_description": "Select one or more people to find photos they appear in.", "permanent_deletion_warning": "Permanent deletion warning", "permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets", "permanently_delete": "Permanently delete", @@ -1653,6 +1678,7 @@ "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", + "prepared_archives": "Prepared Archives", "preparing": "Preparing", "preset": "Preset", "preview": "Preview", @@ -1766,6 +1792,7 @@ "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}", "rename": "Rename", "repository": "Repository", + "request_received_description": "You have been invited to another group", "require_password": "Require password", "rescan": "Rescan", "reset": "Reset", @@ -1788,13 +1815,17 @@ "restore_all": "Restore all", "restore_user": "Restore user", "restored_asset": "Restored asset", + "result": "Result", "resume": "Resume", "resume_paused_jobs": "Resume {count, plural, one {# paused job} other {# paused jobs}}", + "retry": "Retry", "review_duplicates": "Review duplicates", "review_large_files": "Review large files", "role": "Role", "role_editor": "Editor", "role_viewer": "Viewer", + "rotate_api_key_prompt": "Are you sure you want to rotate this API key? The current key will stop working immediately.", + "rotate_key": "Rotate key", "running": "Running", "save": "Save", "saved": "Saved", @@ -1830,17 +1861,24 @@ "search_filter_apply": "Apply filter", "search_filter_camera_title": "Select camera type", "search_filter_date": "Date", + "search_filter_date_custom": "Custom", + "search_filter_date_description": "Filter by when your photos were taken using a preset range or custom date window.", "search_filter_date_interval": "{start} to {end}", + "search_filter_date_last_30_days": "Last 30 days", + "search_filter_date_last_year": "Last year", + "search_filter_date_this_year": "This year", "search_filter_date_title": "Select a date range", "search_filter_display_option_not_in_album": "Not in album", "search_filter_display_options": "Display Options", "search_filter_filename": "Search by file name", "search_filter_location": "Location", + "search_filter_location_description": "Search for photos taken in a specific city, country, or location.", "search_filter_location_title": "Select location", "search_filter_media_type": "Media Type", "search_filter_media_type_title": "Select media type", "search_filter_people_title": "Select people", "search_filter_star_rating": "Star Rating", + "search_filter_tags_description": "Filter by tags you've added to organize your library.", "search_filter_tags_title": "Select tags", "search_for": "Search for", "search_for_existing_person": "Search for existing person", @@ -1859,6 +1897,7 @@ "search_tags": "Search tags...", "search_timezone": "Search timezone...", "search_type": "Search type", + "search_type_description": "Choose how Immich interprets your search: by visual content, file details, or embedded text.", "search_your_photos": "Search your photos", "searching_locales": "Searching locales...", "second": "Second", @@ -2090,6 +2129,7 @@ "tag_face": "Tag face", "tag_feature_description": "Browsing photos and videos grouped by logical tag topics", "tag_people": "Tag People", + "tag_plus_more_tags": "{tag} + {count, plural, one {# tag} other {# tags}}", "tag_updated": "Updated tag: {tag}", "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", "tags": "Tags", @@ -2139,6 +2179,8 @@ "trigger": "Trigger", "trigger_asset_metadata_extraction": "Asset Metadata Extraction", "trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted", + "trigger_asset_tagged": "Asset Tagged", + "trigger_asset_tagged_description": "Triggered when a tag is added to an asset", "trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_person_recognized": "Person Recognized", @@ -2245,6 +2287,7 @@ "view_all_users": "View all users", "view_asset_owners": "View asset owners", "view_details": "View Details", + "view_group": "View group", "view_in_timeline": "View in timeline", "view_link": "View link", "view_name": "View", @@ -2276,6 +2319,13 @@ "workflow_info": "Workflow info", "workflow_json": "Workflow JSON", "workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.", + "workflow_logging_completed": "Completed", + "workflow_logging_disable": "Disable logging", + "workflow_logging_disabled_description": "Logging is currently disabled for this workflow.", + "workflow_logging_enable": "Enable logging", + "workflow_logging_error_step": "Error on step #{step}", + "workflow_logging_halted": "Halted", + "workflow_logging_halted_step": "Halted on step #{step}", "workflow_name": "Workflow name", "workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?", "workflow_summary": "Workflow summary", @@ -2288,6 +2338,7 @@ "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", "yes": "Yes", + "you": "You", "you_dont_have_any_shared_links": "You don't have any shared links", "your_wifi_name": "Your Wi-Fi name", "zero_to_clear_rating": "press 0 to clear asset rating", diff --git a/mise.lock b/mise.lock index 836712e0fd..137b1f1c06 100644 --- a/mise.lock +++ b/mise.lock @@ -291,43 +291,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.12.5/tofu_1.12. url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/484602547" [[tools.pnpm]] -version = "11.20.0" +version = "11.21.0" backend = "aqua:pnpm/pnpm" [tools.pnpm."platforms.linux-arm64"] -checksum = "sha256:f00fc2041bb41742b7943bf2bb24183ad20320e8384824a8031eb94edf2f57a5" -url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142378" +checksum = "sha256:64eb219b008f7a4c176d81fbce919c20b0b7e093e815cbb669d46e681451f43b" +url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585528" provenance = "github-attestations" [tools.pnpm."platforms.linux-arm64-musl"] -checksum = "sha256:2a21235d3f0fbfbcb9e530b9a98f2c64dcee95d1d5d1c2d04428255c7b85f32c" -url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-arm64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142372" +checksum = "sha256:43587a1f3d26ee009c640378ef377cac3531990579acf0f35646531b7832831d" +url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-arm64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585511" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64"] -checksum = "sha256:b4ad6ad2b21db2f8cd50af416c3aa148ba704c31c84893f465a770a01c2c4572" -url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142376" +checksum = "sha256:aadc489ce4473c2af0fec06a5c19e113b5793d404eac49853c8153bf4b0d8263" +url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585503" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl"] -checksum = "sha256:db046881c027f1a2d69e9a530a21a62c2bced4ba8ff437e7300426ae56951fe4" -url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-linux-x64-musl.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142377" +checksum = "sha256:8cad0a4d20318c0445d992630ace51edc0853fd259573f50ee5d0216dce9b420" +url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585524" provenance = "github-attestations" [tools.pnpm."platforms.macos-arm64"] -checksum = "sha256:4bc97fea72e5c92eec1fefc8d410c35d01e0d0d52f3160c59f38c32db58b5cd2" -url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-darwin-arm64.tar.gz" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142373" +checksum = "sha256:860a96978a79ca5132bdc247375f94150a3c98edbecbee36e9f8e0c9e2f7f056" +url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585526" provenance = "github-attestations" [tools.pnpm."platforms.windows-x64"] -checksum = "sha256:ea2528bdc3d96a1ff3c35587dc48ca692b39d77f08f26df4adeaaa9eb427024e" -url = "https://github.com/pnpm/pnpm/releases/download/v11.20.0/pnpm-win32-x64.zip" -url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/500142371" +checksum = "sha256:6643043caa3b01c65c431039926a2af954326d5cefbdb4da7baceceb17f5051f" +url = "https://github.com/pnpm/pnpm/releases/download/v11.21.0/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/507585516" provenance = "github-attestations" [[tools.terragrunt]] diff --git a/mise.toml b/mise.toml index 0039dbac2d..3ff8c22035 100644 --- a/mise.toml +++ b/mise.toml @@ -16,7 +16,7 @@ config_roots = [ [tools] node = "24.15.0" -pnpm = "11.20.0" +pnpm = "11.21.0" terragrunt = "1.1.1" opentofu = "1.12.5" "npm:@openapitools/openapi-generator-cli" = "2.40.1" @@ -73,7 +73,6 @@ outputs = ["../mobile/generated/openapi/"] run = "bash ./bin/generate-dart-sdk.sh" [tasks.open-api] -env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true } run = [ { task = "//:plugins" }, { task = "//server:install" }, diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index fb5d96697b..065b3d56d8 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -26,52 +26,59 @@ linter: # producing the lint. rules: - # Formatting - avoid_print: true - require_trailing_commas: true - unrelated_type_equality_checks: true - prefer_const_constructors: true - always_use_package_imports: true - always_put_control_body_on_new_line: true + # Correctness + always_declare_return_types: true + avoid_type_to_string: true + avoid_unused_constructor_parameters: true + avoid_void_async: true + cancel_subscriptions: true + cast_nullable_to_non_nullable: true + close_sinks: true + collection_methods_unrelated_type: true + deprecated_consistency: true + discarded_futures: true + no_adjacent_strings_in_list: true + no_self_assignments: true + noop_primitive_operations: true + only_throw_errors: true + parameter_assignments: true + throw_in_finally: true + tighten_type_of_initializing_formals: true + unawaited_futures: true + unnecessary_ignore: true unnecessary_null_checks: true - unnecessary_parenthesis: true - prefer_final_locals: true + unnecessary_statements: true + unrelated_type_equality_checks: true + + # Performance + avoid_slow_async_io: true + prefer_const_constructors: true prefer_const_declarations: true prefer_const_literals_to_create_immutables: true - use_super_parameters: true - directives_ordering: true - no_leading_underscores_for_local_identifiers: true - always_declare_return_types: true - avoid_void_async: true - noop_primitive_operations: true - use_named_constants: true - combinators_ordering: true - avoid_multiple_declarations_per_line: true - unnecessary_breaks: true - - # Correctness - no_adjacent_strings_in_list: true - cancel_subscriptions: true - close_sinks: true - unawaited_futures: true - discarded_futures: true - no_self_assignments: true - throw_in_finally: true - collection_methods_unrelated_type: true - cast_nullable_to_non_nullable: true - - # Known issues - avoid_slow_async_io: true - avoid_type_to_string: true # Flutter specific - use_build_context_synchronously: true + avoid_unnecessary_containers: true sized_box_for_whitespace: true + use_build_context_synchronously: true use_colored_box: true use_decorated_box: true - avoid_unnecessary_containers: true use_full_hex_values_for_flutter_colors: true + # Style + always_put_control_body_on_new_line: true + always_use_package_imports: true + avoid_multiple_declarations_per_line: true + avoid_print: true + combinators_ordering: true + directives_ordering: true + no_leading_underscores_for_local_identifiers: true + prefer_final_locals: true + require_trailing_commas: true + unnecessary_breaks: true + unnecessary_parenthesis: true + use_named_constants: true + use_super_parameters: true + # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options analyzer: diff --git a/mobile/build.yaml b/mobile/build.yaml index cb718d9d3e..32cd96c5d3 100644 --- a/mobile/build.yaml +++ b/mobile/build.yaml @@ -18,8 +18,7 @@ targets: generate_for: &drift_generate_for - lib/infrastructure/entities/*.dart - lib/infrastructure/entities/*.drift - - lib/infrastructure/repositories/db.repository.dart - - lib/infrastructure/repositories/logger_db.repository.dart + - lib/infrastructure/repositories/*.repository.dart drift_dev:modular: enabled: true options: *drift_options diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index da0f59f87d..4cf0fac890 100644 --- a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "84a79bc375a301169390ac110c868f06c857b83f", - "version" : "6.27.0" + "revision" : "5ee345ca5d65238a6fce29bba87816204be7df20", + "version" : "6.28.0" } }, { diff --git a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift index a7bbc31ceb..0eee970e71 100644 --- a/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift +++ b/mobile/ios/Runner/Background/BackgroundWorkerApiImpl.swift @@ -22,8 +22,9 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi { print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers") } - private static let refreshTaskID = "app.alextran.immich.background.refreshUpload" - private static let processingTaskID = "app.alextran.immich.background.processingUpload" + private static let taskIDs = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as! [String] + private static let refreshTaskID = taskIDs.first { $0.hasSuffix(".refreshUpload") }! + private static let processingTaskID = taskIDs.first { $0.hasSuffix(".processingUpload") }! private static let taskSemaphore = DispatchSemaphore(value: 1) public static func registerBackgroundWorkers() { diff --git a/mobile/ios/scripts/xcode_flutter_patch.sh b/mobile/ios/scripts/xcode_flutter_patch.sh new file mode 100755 index 0000000000..95c92d0b49 --- /dev/null +++ b/mobile/ios/scripts/xcode_flutter_patch.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Makes Flutter not incorrectly append `-sdk` arguments to simulator builds +# This breaks macros that must build for the host's platform +# +# Flutter was informed of this (https://github.com/flutter/flutter/issues/146122) but has not fixed it in 2 years + +set -eu +sdk="${MISE_TOOL_INSTALL_PATH:-$(mise where aqua:flutter/flutter)}/flutter" +mac_dart="$sdk/packages/flutter_tools/lib/src/ios/mac.dart" + +pattern="buildCommands.addAll(['-sdk', XcodeSdk.IPhoneSimulator.platformName]);" + +# Filter out the sdk arg pattern +if awk -v pat="$pattern" 'index($0, pat) { found=1; next } { print } END { exit !found }' "$mac_dart" > "$mac_dart.tmp"; then + # If it was filtered out, apply it + mv "$mac_dart.tmp" "$mac_dart" + + # Force flutter itself to rebuild + rm -f "$sdk/bin/cache/flutter_tools.snapshot" "$sdk/bin/cache/flutter_tools.stamp" + + echo "flutter postinstall: removed simulator -sdk flag from xcodebuild invocation" +else + rm -f "$mac_dart.tmp" +fi diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart index a75a4a4f0f..1c556f382f 100644 --- a/mobile/lib/domain/models/memory.model.dart +++ b/mobile/lib/domain/models/memory.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart index 56e6d3990d..94ead6a2e9 100644 --- a/mobile/lib/domain/models/person.model.dart +++ b/mobile/lib/domain/models/person.model.dart @@ -3,32 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'person.model.freezed.dart'; -// TODO: Remove PersonDto once Isar is removed @freezed -abstract class PersonDto with _$PersonDto { - const factory PersonDto({ - required String id, - DateTime? birthDate, - required bool isHidden, - required String name, - required String thumbnailPath, - DateTime? updatedAt, - }) = _PersonDto; -} - -// Model for a person stored in the server -@freezed -abstract class DriftPerson with _$DriftPerson { - const factory DriftPerson({ - required String id, - required DateTime createdAt, - required DateTime updatedAt, - required String ownerId, - required String name, - String? faceAssetId, - required bool isFavorite, - required bool isHidden, - required String? color, - DateTime? birthDate, - }) = _DriftPerson; +abstract class Person with _$Person { + const factory Person({required String id, required String name, DateTime? updatedAt, DateTime? birthDate}) = _Person; } diff --git a/mobile/lib/domain/models/server_capability.model.dart b/mobile/lib/domain/models/server_capability.model.dart new file mode 100644 index 0000000000..ece9850594 --- /dev/null +++ b/mobile/lib/domain/models/server_capability.model.dart @@ -0,0 +1,23 @@ +import 'package:immich_mobile/utils/semver.dart'; + +enum ServerCapability { + // Feature Support + cloudIdMetadata(SemVer(major: 2, minor: 4, patch: 0)), + bulkCloudIdMetadata(SemVer(major: 2, minor: 5, patch: 0)), + assetEdits(SemVer(major: 2, minor: 6, patch: 0)), + assetFacesV2(SemVer(major: 2, minor: 6, patch: 0)), + syncV2(SemVer(major: 3, minor: 0, patch: 0)), + assetOcr(SemVer(major: 3, minor: 0, patch: 0)), + + // Migrations + assetPayloadChange20260128(SemVer(major: 2, minor: 5, patch: 0)), + assetPayloadChange20260597(SemVer(major: 2, minor: 7, patch: 6)); + + const ServerCapability(this.minVersion); + + final SemVer minVersion; +} + +extension ServerCapabilitySupport on SemVer { + bool supports(ServerCapability capability) => this >= capability.minVersion; +} diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index 96e07137c9..181fddf581 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:ui'; import 'package:freezed_annotation/freezed_annotation.dart'; diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 59f41f1e51..0032403348 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -17,10 +17,10 @@ import 'package:maplibre_gl/maplibre_gl.dart'; class AssetService { final RemoteAssetRepository _remoteRepository; final RemoteExifRepository _exifRepository; - final DriftLocalAssetRepository _localRepository; + final LocalAssetRepository _localRepository; final AssetApiRepository _apiRepository; final AssetMediaRepository _mediaRepository; - final DriftTrashedLocalAssetRepository _trashedLocalRepository; + final TrashedLocalAssetRepository _trashedLocalRepository; const AssetService({ required this._remoteRepository, @@ -136,13 +136,13 @@ class AssetService { location: location, dateTimeOriginal: dateTime, ); - await _remoteRepository.update( + await _remoteRepository.updateAssets( remoteIds, isFavorite: isFavorite, visibility: visibility, createdAt: parsedDateTime, ); - await _exifRepository.update( + await _exifRepository.updateExif( remoteIds, location: location, dateTimeOriginal: parsedDateTime, @@ -165,7 +165,7 @@ class AssetService { } await _apiRepository.delete(remoteIds, true); - await _remoteRepository.delete(remoteIds); + await _remoteRepository.deleteAssets(remoteIds); } Future applyEdits(String remoteId, List edits) async { @@ -189,7 +189,7 @@ class AssetService { if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) { await _trashedLocalRepository.applyTrashedAssets(deletedIds); } else { - await _localRepository.delete(deletedIds); + await _localRepository.deleteAssets(deletedIds); } return deletedIds.length; } diff --git a/mobile/lib/domain/services/background_worker.service.dart b/mobile/lib/domain/services/background_worker.service.dart index 8161df5c51..817cc33380 100644 --- a/mobile/lib/domain/services/background_worker.service.dart +++ b/mobile/lib/domain/services/background_worker.service.dart @@ -19,8 +19,6 @@ import 'package:immich_mobile/platform/background_worker_api.g.dart'; import 'package:immich_mobile/platform/background_worker_lock_api.g.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; @@ -76,31 +74,32 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi { : _backgroundHostApi = BackgroundWorkerBgHostApi() { final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]); _ref = ref; + final db = ref.read(driftProvider); _localSyncService = LocalSyncService( - localAlbumRepository: ref.read(localAlbumRepository), - localAssetRepository: ref.read(localAssetRepository), + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, nativeSyncApi: ref.read(nativeSyncApiProvider), - trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.read(assetMediaRepositoryProvider), permissionRepository: ref.read(permissionRepositoryProvider), cancellation: _cancellationToken, ); _remoteSyncService = SyncStreamService( syncApiRepository: ref.read(syncApiRepositoryProvider), - syncStreamRepository: ref.read(syncStreamRepositoryProvider), - localAssetRepository: ref.read(localAssetRepository), - trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + syncStreamRepository: db.syncStreamRepository, + localAssetRepository: db.localAssetRepository, + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.read(assetMediaRepositoryProvider), permissionRepository: ref.read(permissionRepositoryProvider), - syncMigrationRepository: ref.read(syncMigrationRepositoryProvider), + syncMigrationRepository: db.syncMigrationRepository, api: ref.read(apiServiceProvider), cancellation: _cancellationToken, ); _hashService = HashService( - localAlbumRepository: ref.read(localAlbumRepository), - localAssetRepository: ref.read(localAssetRepository), + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, nativeSyncApi: ref.read(nativeSyncApiProvider), - trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository), + trashedLocalAssetRepository: db.trashedLocalAssetRepository, cancellation: _cancellationToken, ); BackgroundWorkerFlutterApi.setUp(this); diff --git a/mobile/lib/domain/services/hash.service.dart b/mobile/lib/domain/services/hash.service.dart index 8a573ee209..64595aff2b 100644 --- a/mobile/lib/domain/services/hash.service.dart +++ b/mobile/lib/domain/services/hash.service.dart @@ -15,9 +15,9 @@ const String _kHashCancelledCode = "HASH_CANCELLED"; class HashService { final int _batchSize; - final DriftLocalAlbumRepository _localAlbumRepository; - final DriftLocalAssetRepository _localAssetRepository; - final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final LocalAlbumRepository _localAlbumRepository; + final LocalAssetRepository _localAssetRepository; + final TrashedLocalAssetRepository _trashedLocalAssetRepository; final NativeSyncApi _nativeSyncApi; final Completer? _cancellation; final _log = Logger('HashService'); diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index e3d888f063..8661a8d10a 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -3,7 +3,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; class LocalAlbumService { - final DriftLocalAlbumRepository _repository; + final LocalAlbumRepository _repository; const LocalAlbumService(this._repository); diff --git a/mobile/lib/domain/services/local_sync.service.dart b/mobile/lib/domain/services/local_sync.service.dart index 9e272ac65f..0793d191b0 100644 --- a/mobile/lib/domain/services/local_sync.service.dart +++ b/mobile/lib/domain/services/local_sync.service.dart @@ -21,11 +21,11 @@ import 'package:logging/logging.dart'; const String _kSyncCancelledCode = "SYNC_CANCELLED"; class LocalSyncService { - final DriftLocalAlbumRepository _localAlbumRepository; + final LocalAlbumRepository _localAlbumRepository; // ignore: unused_field - final DriftLocalAssetRepository _localAssetRepository; + final LocalAssetRepository _localAssetRepository; final NativeSyncApi _nativeSyncApi; - final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final TrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; final DevicePermissionRepository _permissionRepository; final Completer? _cancellation; @@ -186,7 +186,7 @@ class LocalSyncService { _log.fine("Removing device album ${a.name}"); try { // Asset deletion is handled in the repository - await _localAlbumRepository.delete(a.id); + await _localAlbumRepository.deleteAlbum(a.id); } catch (e, s) { _log.warning("Error while removing device album", e, s); } diff --git a/mobile/lib/domain/services/map.service.dart b/mobile/lib/domain/services/map.service.dart index 6c484d1a14..5e0a3bd857 100644 --- a/mobile/lib/domain/services/map.service.dart +++ b/mobile/lib/domain/services/map.service.dart @@ -8,7 +8,7 @@ typedef MapMarkerSource = Future> Function(LatLngBounds? bounds); typedef MapQuery = ({MapMarkerSource markerSource}); class MapFactory { - final DriftMapRepository _mapRepository; + final MapRepository _mapRepository; const MapFactory({required this._mapRepository}); diff --git a/mobile/lib/domain/services/memory.service.dart b/mobile/lib/domain/services/memory.service.dart index a1fc1b2797..649d5c8acf 100644 --- a/mobile/lib/domain/services/memory.service.dart +++ b/mobile/lib/domain/services/memory.service.dart @@ -5,7 +5,7 @@ import 'package:logging/logging.dart'; class DriftMemoryService { final log = Logger("DriftMemoryService"); - final DriftMemoryRepository _repository; + final MemoryRepository _repository; DriftMemoryService(this._repository); diff --git a/mobile/lib/domain/services/partner.service.dart b/mobile/lib/domain/services/partner.service.dart index 63985823aa..3fd0d32683 100644 --- a/mobile/lib/domain/services/partner.service.dart +++ b/mobile/lib/domain/services/partner.service.dart @@ -27,7 +27,7 @@ class PartnerService { Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) async { await _partnerApiRepository.update(sharedById, inTimeline: inTimeline); - await _partnerRepository.update(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline); + await _partnerRepository.updatePartner(sharedById: sharedById, sharedWithId: sharedWithId, inTimeline: inTimeline); } Future create({required String sharedById, required String sharedWithId, bool inTimeline = false}) async { @@ -37,6 +37,6 @@ class PartnerService { Future delete({required String sharedById, required String sharedWithId}) async { await _partnerApiRepository.delete(sharedWithId); - await _partnerRepository.delete(sharedById: sharedById, sharedWithId: sharedWithId); + await _partnerRepository.deletePartner(sharedById: sharedById, sharedWithId: sharedWithId); } } diff --git a/mobile/lib/domain/services/people.service.dart b/mobile/lib/domain/services/people.service.dart index 73972dbdc0..e0323246b0 100644 --- a/mobile/lib/domain/services/people.service.dart +++ b/mobile/lib/domain/services/people.service.dart @@ -5,21 +5,21 @@ import 'package:immich_mobile/infrastructure/repositories/people.repository.dart import 'package:immich_mobile/repositories/person_api.repository.dart'; class DriftPeopleService { - final DriftPeopleRepository _repository; + final PeopleRepository _repository; final PersonApiRepository _personApiRepository; const DriftPeopleService(this._repository, this._personApiRepository); - Future get(String personId) { + Future get(String personId) { return _repository.get(personId); } - Future> getAssetPeople(String assetId) { + Future> getAssetPeople(String assetId) { return _repository.getAssetPeople(assetId); } - Future> getAllPeople({int minFaces = 3}) { - return _repository.getAllPeople(minFaces: minFaces); + Stream> watch({int minFaces = 3}) { + return _repository.watch(minFaces: minFaces); } Future updateName(String personId, String name) async { @@ -27,7 +27,7 @@ class DriftPeopleService { return _repository.updateName(personId, name); } - Future updateBrithday(String personId, DateTime birthday) async { + Future updateBirthday(String personId, DateTime birthday) async { await _personApiRepository.update(personId, birthday: birthday); return _repository.updateBirthday(personId, birthday); } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index e59d75b01a..8e6a6c8aae 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -25,7 +25,7 @@ class AlbumAssetCandidates { class RemoteAlbumService { static final _logger = Logger('RemoteAlbumService'); - final DriftRemoteAlbumRepository _repository; + final RemoteAlbumRepository _repository; final DriftAlbumApiRepository _albumApiRepository; final ForegroundUploadService _uploadService; @@ -152,7 +152,7 @@ class RemoteAlbumService { ); // Update the local database - await _repository.update(updatedAlbum); + await _repository.updateAlbum(updatedAlbum); return updatedAlbum; } diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart index 758622a43b..ea830dc322 100644 --- a/mobile/lib/domain/services/store.service.dart +++ b/mobile/lib/domain/services/store.service.dart @@ -6,13 +6,13 @@ import 'package:immich_mobile/infrastructure/repositories/store.repository.dart' /// Provides access to a persistent key-value store with an in-memory cache. /// Listens for repository changes to keep the cache updated. class StoreService { - final DriftStoreRepository _storeRepository; + final StoreRepository _storeRepository; /// In-memory cache. Keys are [StoreKey.id] final Map _cache = {}; StreamSubscription>? _storeUpdateSubscription; - StoreService._({required DriftStoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository; + StoreService._({required StoreRepository isarStoreRepository}) : _storeRepository = isarStoreRepository; // TODO: Temporary typedef to make minimal changes. Remove this and make the presentation layer access store through a provider static StoreService? _instance; @@ -24,12 +24,12 @@ class StoreService { } // TODO: Replace the implementation with the one from create after removing the typedef - static Future init({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async { + static Future init({required StoreRepository storeRepository, bool listenUpdates = true}) async { _instance ??= await create(storeRepository: storeRepository, listenUpdates: listenUpdates); return _instance!; } - static Future create({required DriftStoreRepository storeRepository, bool listenUpdates = true}) async { + static Future create({required StoreRepository storeRepository, bool listenUpdates = true}) async { final instance = StoreService._(isarStoreRepository: storeRepository); await instance.populateCache(); if (listenUpdates) { @@ -90,7 +90,7 @@ class StoreService { /// Removes the value for [key] Future delete(StoreKey key) async { - await _storeRepository.delete(key); + await _storeRepository.deleteValue(key); _cache.remove(key.id); } diff --git a/mobile/lib/domain/services/sync_linked_album.service.dart b/mobile/lib/domain/services/sync_linked_album.service.dart index 091ca222f0..80a1fbaf9e 100644 --- a/mobile/lib/domain/services/sync_linked_album.service.dart +++ b/mobile/lib/domain/services/sync_linked_album.service.dart @@ -6,24 +6,25 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/utils/debug_print.dart'; import 'package:logging/logging.dart'; -final syncLinkedAlbumServiceProvider = Provider( - (ref) => SyncLinkedAlbumService( - ref.watch(localAlbumRepository), - ref.watch(remoteAlbumRepository), +final syncLinkedAlbumServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return SyncLinkedAlbumService( + db.localAlbumRepository, + db.remoteAlbumRepository, ref.watch(driftAlbumApiRepositoryProvider), ref.watch(storeServiceProvider), - ), -); + ); +}); class SyncLinkedAlbumService { - final DriftLocalAlbumRepository _localAlbumRepository; - final DriftRemoteAlbumRepository _remoteAlbumRepository; + final LocalAlbumRepository _localAlbumRepository; + final RemoteAlbumRepository _remoteAlbumRepository; final DriftAlbumApiRepository _albumApiRepository; final StoreService _storeService; diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 0eb3ff2f99..2f6ad3f045 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; @@ -33,8 +34,8 @@ class SyncStreamService { final SyncApiRepository _syncApiRepository; final SyncStreamRepository _syncStreamRepository; - final DriftLocalAssetRepository _localAssetRepository; - final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository; + final LocalAssetRepository _localAssetRepository; + final TrashedLocalAssetRepository _trashedLocalAssetRepository; final AssetMediaRepository _assetMediaRepository; final DevicePermissionRepository _permissionRepository; final SyncMigrationRepository _syncMigrationRepository; @@ -122,7 +123,7 @@ class SyncStreamService { } if (!migrations.contains(SyncMigrationTask.v20260128_ResetAssetV1.name) && - semVer >= const SemVer(major: 2, minor: 5, patch: 0)) { + semVer.supports(.assetPayloadChange20260128)) { _logger.info("Running pre-sync task: v20260128_ResetAssetV1"); await _syncApiRepository.deleteSyncAck([ SyncEntityType.assetV1, @@ -139,7 +140,7 @@ class SyncStreamService { } if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) && - semVer > const SemVer(major: 2, minor: 7, patch: 5)) { + semVer.supports(.assetPayloadChange20260597)) { _logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2"); await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]); migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name); diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index b20ba306ff..84895b6056 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -38,7 +38,7 @@ enum TimelineOrigin { } class TimelineFactory { - final DriftTimelineRepository _timelineRepository; + final TimelineRepository _timelineRepository; final SettingsRepository _settingsRepository; const TimelineFactory({required this._timelineRepository, required this._settingsRepository}); diff --git a/mobile/lib/domain/utils/migrate_cloud_ids.dart b/mobile/lib/domain/utils/migrate_cloud_ids.dart index efef6e8327..ecdf5fc9a0 100644 --- a/mobile/lib/domain/utils/migrate_cloud_ids.dart +++ b/mobile/lib/domain/utils/migrate_cloud_ids.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/asset_metadata.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -31,12 +32,12 @@ Future syncCloudIds(ProviderContainer ref) async { await _populateCloudIds(db); final serverInfo = await ref.read(serverInfoProvider.notifier).getServerInfo(); - final canUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 4); + final canUpdateMetadata = serverInfo.serverVersion.supports(.cloudIdMetadata); if (!canUpdateMetadata) { logger.fine('Server version does not support asset metadata updates. Skipping cloudId migration.'); return; } - final canBulkUpdateMetadata = serverInfo.serverVersion.isAtLeast(major: 2, minor: 5); + final canBulkUpdateMetadata = serverInfo.serverVersion.supports(.bulkCloudIdMetadata); // Wait for remote sync to complete, so we have up-to-date asset metadata entries try { @@ -171,7 +172,7 @@ Future _populateCloudIds(Drift drift) async { ); } } - await DriftLocalAlbumRepository(drift).updateCloudMapping(cloudMapping); + await LocalAlbumRepository(drift).updateCloudMapping(cloudMapping); } typedef _CloudIdMapping = ({String remoteAssetId, LocalAsset localAsset}); diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index eb3cba95d0..c4c07118d6 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -1,21 +1,18 @@ import 'dart:async'; import 'package:drift/drift.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -final backupRepositoryProvider = Provider( - (ref) => DriftBackupRepository(ref.watch(driftProvider)), -); +@DriftAccessor() +class BackupRepository extends DatabaseAccessor with $BackupRepositoryMixin { + BackupRepository(super.attachedDatabase); -class DriftBackupRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftBackupRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; JoinedSelectStatement<$LocalAlbumAssetEntityTable, LocalAlbumAssetEntityData> _getExcludedSubquery() { return _db.localAlbumAssetEntity.selectOnly() diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 69e45685de..195f1455d2 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -32,8 +32,27 @@ import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/people.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -69,6 +88,28 @@ import 'package:sqlite_async/sqlite_async.dart'; AssetOcrEntity, ], include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'}, + daos: [ + AuthUserRepository, + BackupRepository, + LocalAlbumRepository, + LocalAssetRepository, + MapRepository, + MemoryRepository, + OcrRepository, + PartnerRepository, + PeopleRepository, + RemoteAlbumRepository, + RemoteAssetRepository, + RemoteExifRepository, + StackRepository, + StoreRepository, + SyncMigrationRepository, + SyncStreamRepository, + TimelineRepository, + TrashedLocalAssetRepository, + UserMetadataRepository, + UserRepository, + ], ) class Drift extends $Drift { final SqliteConnectionPool? _updatePool; @@ -344,13 +385,6 @@ class Drift extends $Drift { ); } -class DriftDatabaseRepository { - final Drift _db; - const DriftDatabaseRepository(this._db); - - Future transaction(Future Function() callback) => _db.transaction(callback); -} - // ignore: invalid_use_of_internal_member final class _DriftPoolStreamQueries extends StreamQueryStore { _DriftPoolStreamQueries(this._pool); diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index a9911fe044..780b4f5ef9 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -10,13 +10,15 @@ import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.d import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.drift.dart'; enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum, name, assetCount, newestAsset } -class DriftLocalAlbumRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class LocalAlbumRepository extends DatabaseAccessor with $LocalAlbumRepositoryMixin { + LocalAlbumRepository(super.attachedDatabase); - const DriftLocalAlbumRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; Future> getAll({Set sortBy = const {}}) { final assetCount = _db.localAlbumAssetEntity.assetId.count(); @@ -57,7 +59,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future delete(String albumId) => transaction(() async { + Future deleteAlbum(String albumId) => transaction(() async { // Remove all assets that are only in this particular album // We cannot remove all assets in the album because they might be in other albums in iOS // That is not the case on Android since asset <-> album has one:one mapping diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 8396d6d2a6..e035bddea4 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.drift.dart'; class RemovalCandidatesResult { final List assets; @@ -18,10 +19,11 @@ class RemovalCandidatesResult { const RemovalCandidatesResult({required this.assets, required this.totalBytes}); } -class DriftLocalAssetRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class LocalAssetRepository extends DatabaseAccessor with $LocalAssetRepositoryMixin { + LocalAssetRepository(super.attachedDatabase); - const DriftLocalAssetRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; SingleOrNullSelectable _assetSelectable(String id) { final query = @@ -72,7 +74,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { }); } - Future delete(List ids) { + Future deleteAssets(List ids) { if (ids.isEmpty) { return Future.value(); } diff --git a/mobile/lib/infrastructure/repositories/map.repository.dart b/mobile/lib/infrastructure/repositories/map.repository.dart index 267ea08d20..bd24985fbb 100644 --- a/mobile/lib/infrastructure/repositories/map.repository.dart +++ b/mobile/lib/infrastructure/repositories/map.repository.dart @@ -5,13 +5,15 @@ import 'package:immich_mobile/domain/services/map.service.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/map.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class DriftMapRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class MapRepository extends DatabaseAccessor with $MapRepositoryMixin { + MapRepository(super.attachedDatabase); - const DriftMapRepository(super._db) : _db = _db; + Drift get _db => attachedDatabase; MapQuery remote(List ownerIds, TimelineMapOptions options) => _mapQueryBuilder( assetFilter: (row) { diff --git a/mobile/lib/infrastructure/repositories/memory.repository.dart b/mobile/lib/infrastructure/repositories/memory.repository.dart index 8a61f6a4ab..f24680745f 100644 --- a/mobile/lib/infrastructure/repositories/memory.repository.dart +++ b/mobile/lib/infrastructure/repositories/memory.repository.dart @@ -4,10 +4,13 @@ import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.drift.dart'; -class DriftMemoryRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftMemoryRepository(this._db) : super(_db); +@DriftAccessor() +class MemoryRepository extends DatabaseAccessor with $MemoryRepositoryMixin { + MemoryRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getAll(String ownerId, {bool onlyToday = true}) async { final query = diff --git a/mobile/lib/infrastructure/repositories/ocr.repository.dart b/mobile/lib/infrastructure/repositories/ocr.repository.dart index c3465df357..f374937b4a 100644 --- a/mobile/lib/infrastructure/repositories/ocr.repository.dart +++ b/mobile/lib/infrastructure/repositories/ocr.repository.dart @@ -2,10 +2,13 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/ocr.model.dart'; import 'package:immich_mobile/infrastructure/entities/asset_ocr.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/ocr.repository.drift.dart'; -class OcrRepository extends DriftDatabaseRepository { - final Drift _db; - const OcrRepository(this._db) : super(_db); +@DriftAccessor() +class OcrRepository extends DatabaseAccessor with $OcrRepositoryMixin { + OcrRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> get(String assetId) async { final query = _db.select(_db.assetOcrEntity) diff --git a/mobile/lib/infrastructure/repositories/partner.repository.dart b/mobile/lib/infrastructure/repositories/partner.repository.dart index ee18c84b4e..fd38f12686 100644 --- a/mobile/lib/infrastructure/repositories/partner.repository.dart +++ b/mobile/lib/infrastructure/repositories/partner.repository.dart @@ -4,10 +4,13 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; import 'package:immich_mobile/infrastructure/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.drift.dart'; -class PartnerRepository { - final Drift _db; - const PartnerRepository(this._db); +@DriftAccessor() +class PartnerRepository extends DatabaseAccessor with $PartnerRepositoryMixin { + PartnerRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future get({required String sharedById, required String sharedWithId}) => (_db.select(_db.partnerEntity).join([ @@ -46,11 +49,11 @@ class PartnerRepository { ), ); - Future update({required String sharedById, required String sharedWithId, required bool inTimeline}) => + Future updatePartner({required String sharedById, required String sharedWithId, required bool inTimeline}) => (_db.partnerEntity.update()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId))) .write(PartnerEntityCompanion(inTimeline: Value(inTimeline))); - Future delete({required String sharedById, required String sharedWithId}) => + Future deletePartner({required String sharedById, required String sharedWithId}) => (_db.partnerEntity.delete()..where((t) => t.sharedById.equals(sharedById) & t.sharedWithId.equals(sharedWithId))) .go(); diff --git a/mobile/lib/infrastructure/repositories/people.repository.dart b/mobile/lib/infrastructure/repositories/people.repository.dart index 0dd73cc81d..52ba362332 100644 --- a/mobile/lib/infrastructure/repositories/people.repository.dart +++ b/mobile/lib/infrastructure/repositories/people.repository.dart @@ -3,19 +3,22 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/people.repository.drift.dart'; -class DriftPeopleRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftPeopleRepository(this._db) : super(_db); +@DriftAccessor() +class PeopleRepository extends DatabaseAccessor with $PeopleRepositoryMixin { + PeopleRepository(super.attachedDatabase); - Future get(String personId) async { + Drift get _db => attachedDatabase; + + Future get(String personId) async { final query = _db.select(_db.personEntity)..where((row) => row.id.equals(personId)); final result = await query.getSingleOrNull(); return result?.toDto(); } - Future> getAssetPeople(String assetId) async { + Future> getAssetPeople(String assetId) async { // An asset can have multiple face records for the same person (e.g., metadata // imports alongside ML detections). Use a subquery instead of a join so each // person is returned once, regardless of how many of their faces are on the asset @@ -33,7 +36,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()).get(); } - Future> getAllPeople({int minFaces = 3}) async { + Stream> watch({int minFaces = 3}) { final people = _db.personEntity; final faces = _db.assetFaceEntity; final assets = _db.remoteAssetEntity; @@ -59,7 +62,7 @@ class DriftPeopleRepository extends DriftDatabaseRepository { return query.map((row) { final person = row.readTable(people); return person.toDto(); - }).get(); + }).watch(); } Future updateName(String personId, String name) { @@ -76,18 +79,5 @@ class DriftPeopleRepository extends DriftDatabaseRepository { } extension on PersonEntityData { - DriftPerson toDto() { - return DriftPerson( - id: id, - createdAt: createdAt, - updatedAt: updatedAt, - ownerId: ownerId, - name: name, - faceAssetId: faceAssetId, - isFavorite: isFavorite, - isHidden: isHidden, - color: color, - birthDate: birthDate, - ); - } + Person toDto() => Person(id: id, updatedAt: updatedAt, name: name, birthDate: birthDate); } diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index b9cd41c960..2f7106c270 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -12,12 +12,15 @@ import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.d import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.drift.dart'; enum SortRemoteAlbumsBy { id, updatedAt } -class DriftRemoteAlbumRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftRemoteAlbumRepository(this._db) : super(_db); +@DriftAccessor() +class RemoteAlbumRepository extends DatabaseAccessor with $RemoteAlbumRepositoryMixin { + RemoteAlbumRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getAll({Set sortBy = const {SortRemoteAlbumsBy.updatedAt}}) { // Count non-trashed assets via the joined asset table. Filtering trashed assets in the @@ -191,7 +194,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }); } - Future update(RemoteAlbum album) async { + Future updateAlbum(RemoteAlbum album) async { await _db.remoteAlbumEntity.update().replace( RemoteAlbumEntityCompanion( id: Value(album.id), diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index d18284b636..e6fd0e9c89 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -10,13 +10,15 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.drift.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class RemoteAssetRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class RemoteAssetRepository extends DatabaseAccessor with $RemoteAssetRepositoryMixin { + RemoteAssetRepository(super.attachedDatabase); - const RemoteAssetRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; /// For testing purposes Future> getSome(String userId) { @@ -163,7 +165,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository { ); } - Future delete(List ids) { + Future deleteAssets(List ids) { return _db.batch((batch) { for (final id in ids) { batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id)); @@ -244,7 +246,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository { return query.map((row) => row.toDto()!).get(); } - Future update( + Future updateAssets( List remoteIds, { Option isFavorite = const .none(), Option visibility = const .none(), diff --git a/mobile/lib/infrastructure/repositories/remote_exif.repository.dart b/mobile/lib/infrastructure/repositories/remote_exif.repository.dart index 568342b321..f3064104f2 100644 --- a/mobile/lib/infrastructure/repositories/remote_exif.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_exif.repository.dart @@ -1,14 +1,17 @@ +import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.drift.dart'; import 'package:immich_mobile/utils/option.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class RemoteExifRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class RemoteExifRepository extends DatabaseAccessor with $RemoteExifRepositoryMixin { + RemoteExifRepository(super.attachedDatabase); - const RemoteExifRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; - Future update( + Future updateExif( List ids, { Option dateTimeOriginal = const .none(), Option timeZone = const .none(), diff --git a/mobile/lib/infrastructure/repositories/stack.repository.dart b/mobile/lib/infrastructure/repositories/stack.repository.dart index 28f7496f97..eecf7b9147 100644 --- a/mobile/lib/infrastructure/repositories/stack.repository.dart +++ b/mobile/lib/infrastructure/repositories/stack.repository.dart @@ -2,10 +2,13 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/stack.repository.drift.dart'; -class DriftStackRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftStackRepository(this._db) : super(_db); +@DriftAccessor() +class StackRepository extends DatabaseAccessor with $StackRepositoryMixin { + StackRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getAll(String userId) { final query = _db.stackEntity.select()..where((e) => e.ownerId.equals(userId)); diff --git a/mobile/lib/infrastructure/repositories/store.repository.dart b/mobile/lib/infrastructure/repositories/store.repository.dart index 9680aa0425..62cf38c209 100644 --- a/mobile/lib/infrastructure/repositories/store.repository.dart +++ b/mobile/lib/infrastructure/repositories/store.repository.dart @@ -3,13 +3,16 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; -class DriftStoreRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class StoreRepository extends DatabaseAccessor with $StoreRepositoryMixin { final validStoreKeys = StoreKey.values.map((e) => e.id).toSet(); - DriftStoreRepository(super.db) : _db = db; + StoreRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future deleteAll() async { await _db.storeEntity.deleteAll(); @@ -27,7 +30,7 @@ class DriftStoreRepository extends DriftDatabaseRepository { return query.asyncMap((entity) => _toUpdateEvent(entity)).watch(); } - Future delete(StoreKey key) async { + Future deleteValue(StoreKey key) async { await _db.storeEntity.deleteWhere((entity) => entity.id.equals(key.id)); return; } @@ -64,7 +67,7 @@ class DriftStoreRepository extends DriftDatabaseRepository { const (bool) => entity.intValue == 1, const (DateTime) => entity.intValue == null ? null : DateTime.fromMillisecondsSinceEpoch(entity.intValue!), const (UserDto) => - entity.stringValue == null ? null : await DriftAuthUserRepository(_db).get(entity.stringValue!), + entity.stringValue == null ? null : await AuthUserRepository(_db).get(entity.stringValue!), _ => null, } as T?; @@ -75,7 +78,7 @@ class DriftStoreRepository extends DriftDatabaseRepository { const (String) => (null, value as String), const (bool) => ((value as bool) ? 1 : 0, null), const (DateTime) => ((value as DateTime).millisecondsSinceEpoch, null), - const (UserDto) => (null, (await DriftAuthUserRepository(_db).upsert(value as UserDto)).id), + const (UserDto) => (null, (await AuthUserRepository(_db).upsert(value as UserDto)).id), _ => throw UnsupportedError("Unsupported primitive type: ${key.type} for key: ${key.name}"), }; return StoreEntityCompanion(id: Value(key.id), intValue: Value(intValue), stringValue: Value(strValue)); diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index 303859da2f..4e27da1a3d 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/domain/models/sync_event.model.dart'; import 'package:immich_mobile/infrastructure/repositories/network.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -44,25 +45,16 @@ class SyncApiRepository { types: [ SyncRequestType.authUsersV1, SyncRequestType.usersV1, - serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) - ? SyncRequestType.assetsV2 - : SyncRequestType.assetsV1, + serverVersion.supports(.syncV2) ? SyncRequestType.assetsV2 : SyncRequestType.assetsV1, SyncRequestType.assetExifsV1, - if (serverVersion >= const SemVer(major: 2, minor: 6, patch: 0)) SyncRequestType.assetEditsV1, + if (serverVersion.supports(.assetEdits)) SyncRequestType.assetEditsV1, SyncRequestType.assetMetadataV1, SyncRequestType.partnersV1, - serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) - ? SyncRequestType.partnerAssetsV2 - : SyncRequestType.partnerAssetsV1, + serverVersion.supports(.syncV2) ? SyncRequestType.partnerAssetsV2 : SyncRequestType.partnerAssetsV1, SyncRequestType.partnerAssetExifsV1, - if (serverVersion < const SemVer(major: 3, minor: 0, patch: 0)) - SyncRequestType.albumsV1 - else - SyncRequestType.albumsV2, + serverVersion.supports(.syncV2) ? SyncRequestType.albumsV2 : SyncRequestType.albumsV1, SyncRequestType.albumUsersV1, - serverVersion >= const SemVer(major: 3, minor: 0, patch: 0) - ? SyncRequestType.albumAssetsV2 - : SyncRequestType.albumAssetsV1, + serverVersion.supports(.syncV2) ? SyncRequestType.albumAssetsV2 : SyncRequestType.albumAssetsV1, SyncRequestType.albumAssetExifsV1, SyncRequestType.albumToAssetsV1, SyncRequestType.memoriesV1, @@ -71,10 +63,8 @@ class SyncApiRepository { SyncRequestType.partnerStacksV1, SyncRequestType.userMetadataV1, SyncRequestType.peopleV1, - serverVersion >= const SemVer(major: 2, minor: 6, patch: 0) - ? SyncRequestType.assetFacesV2 - : SyncRequestType.assetFacesV1, - if (serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) SyncRequestType.assetOcrV1, + serverVersion.supports(.assetFacesV2) ? SyncRequestType.assetFacesV2 : SyncRequestType.assetFacesV1, + if (serverVersion.supports(.assetOcr)) SyncRequestType.assetOcrV1, ], ).toJson(), ); diff --git a/mobile/lib/infrastructure/repositories/sync_migration.repository.dart b/mobile/lib/infrastructure/repositories/sync_migration.repository.dart index 814c8780ad..eca451dd16 100644 --- a/mobile/lib/infrastructure/repositories/sync_migration.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_migration.repository.dart @@ -1,9 +1,12 @@ +import 'package:drift/drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.drift.dart'; -class SyncMigrationRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class SyncMigrationRepository extends DatabaseAccessor with $SyncMigrationRepositoryMixin { + SyncMigrationRepository(super.attachedDatabase); - const SyncMigrationRepository(super.db) : _db = db; + Drift get _db => attachedDatabase; Future v20260128CopyExifWidthHeightToAsset() async { await _db.customStatement(''' diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index c43de69c5d..ce246b97ab 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -29,16 +29,19 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.drift.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart' as api show AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey; import 'package:openapi/api.dart' hide AlbumUserRole, AssetEditAction, AssetVisibility, UserMetadataKey; -class SyncStreamRepository extends DriftDatabaseRepository { - final Logger _logger = Logger('DriftSyncStreamRepository'); - final Drift _db; +@DriftAccessor() +class SyncStreamRepository extends DatabaseAccessor with $SyncStreamRepositoryMixin { + final Logger _logger = Logger('SyncStreamRepository'); - SyncStreamRepository(super.db) : _db = db; + SyncStreamRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future reset() async { _logger.fine("SyncResetV1 received. Resetting remote entities"); @@ -224,6 +227,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } @@ -263,6 +267,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { batch.insert( _db.remoteAssetEntity, companion.copyWith(id: Value(asset.id)), + mode: InsertMode.insertOrReplace, onConflict: DoUpdate((_) => companion), ); } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 82ad38c80f..824449e939 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.drift.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:stream_transform/stream_transform.dart'; @@ -33,10 +34,11 @@ class TimelineMapOptions { }); } -class DriftTimelineRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class TimelineRepository extends DatabaseAccessor with $TimelineRepositoryMixin { + TimelineRepository(super.attachedDatabase); - const DriftTimelineRepository(super._db) : _db = _db; + Drift get _db => attachedDatabase; Stream> watchTimelineUserIds(String userId) { final query = _db.partnerEntity.selectOnly() @@ -183,7 +185,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository { TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => ( bucketSource: () => _watchRemoteAlbumBucket(albumId, groupBy: groupBy), - assetSource: (offset, count) => _getRemoteAlbumBucketAssets(albumId, offset: offset, count: count), + assetSource: (offset, count) => + _getRemoteAlbumBucketAssets(albumId, groupBy: groupBy, offset: offset, count: count), origin: TimelineOrigin.remoteAlbum, ); @@ -237,7 +240,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository { .handleError((error) => const []); } - Future> _getRemoteAlbumBucketAssets(String albumId, {required int offset, required int count}) async { + Future> _getRemoteAlbumBucketAssets( + String albumId, { + required int offset, + required int count, + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) async { final albumData = await (_db.remoteAlbumEntity.select()..where((row) => row.id.equals(albumId))).getSingleOrNull(); // If album doesn't exist (was deleted), return empty list @@ -264,11 +272,9 @@ class DriftTimelineRepository extends DriftDatabaseRepository { ), ])..where(_db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId)); - if (isAscending) { - query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); - } else { - query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]); - } + query.orderBy( + _assetDateOrder(groupBy, ascending: isAscending).map((order) => order(_db.remoteAssetEntity)).toList(), + ); query.limit(count, offset: offset); @@ -375,13 +381,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository { TimelineQuery place(String place, GroupAssetsBy groupBy) => ( bucketSource: () => _watchPlaceBucket(place, groupBy: groupBy), - assetSource: (offset, count) => _getPlaceBucketAssets(place, offset: offset, count: count), + assetSource: (offset, count) => _getPlaceBucketAssets(place, groupBy: groupBy, offset: offset, count: count), origin: TimelineOrigin.place, ); TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => ( bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy), - assetSource: (offset, count) => _getPersonBucketAssets(userId, personId, offset: offset, count: count), + assetSource: (offset, count) => + _getPersonBucketAssets(userId, personId, groupBy: groupBy, offset: offset, count: count), origin: TimelineOrigin.person, ); @@ -418,7 +425,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository { }).watch(); } - Future> _getPlaceBucketAssets(String place, {required int offset, required int count}) { + Future> _getPlaceBucketAssets( + String place, { + required int offset, + required int count, + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { final query = _db.remoteAssetEntity.select().join([ innerJoin( @@ -432,7 +444,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _db.remoteExifEntity.city.equals(place), ) - ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) + ..orderBy(_assetDateOrder(groupBy).map((order) => order(_db.remoteAssetEntity)).toList()) ..limit(count, offset: offset); return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); } @@ -488,6 +500,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { String personId, { required int offset, required int count, + GroupAssetsBy groupBy = GroupAssetsBy.day, }) { final idQuery = _db.assetFaceEntity.selectOnly() ..addColumns([_db.assetFaceEntity.assetId]) @@ -505,7 +518,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository { row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.timeline), ) - ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]) + ..orderBy(_assetDateOrder(groupBy)) ..limit(count, offset: offset); return query.map((row) => row.toDto()).get(); @@ -715,6 +728,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository { List _generateBuckets(int count) => count == 0 ? const [] : [Bucket(assetCount: count)]; +List _assetDateOrder(GroupAssetsBy groupBy, {bool ascending = false}) { + OrderingTerm order(Expression exp) => ascending ? OrderingTerm.asc(exp) : OrderingTerm.desc(exp); + return [ + if (groupBy != GroupAssetsBy.none) (row) => order(row.effectiveCreatedAt(groupBy)), + (row) => order(row.createdAt), + ]; +} + extension on Expression { Expression dateFmt(GroupAssetsBy groupBy, {bool toLocal = false}) { // DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting diff --git a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart index 0f50776e4e..ee762fd330 100644 --- a/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/trashed_local_asset.repository.dart @@ -8,13 +8,15 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.d import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.drift.dart'; typedef TrashedAsset = ({String albumId, LocalAsset asset}); -class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository { - final Drift _db; +@DriftAccessor() +class TrashedLocalAssetRepository extends DatabaseAccessor with $TrashedLocalAssetRepositoryMixin { + TrashedLocalAssetRepository(super.attachedDatabase); - const DriftTrashedLocalAssetRepository(this._db) : super(_db); + Drift get _db => attachedDatabase; /// Matches remote_asset_entity rows owned by the current user. The asset is unique over (owner, checksum), /// so partners can have a duplicate checksum diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index 6df7344991..157851ab58 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -4,18 +4,23 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'; import 'package:immich_mobile/infrastructure/mapper.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/user.repository.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; -class UserRepository { - final Drift _db; - const UserRepository(this._db); +@DriftAccessor() +class UserRepository extends DatabaseAccessor with $UserRepositoryMixin { + UserRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Stream> getAll() => _db.select(_db.userEntity).map(mapToUser).watch(); } -class DriftAuthUserRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftAuthUserRepository(super.db) : _db = db; +@DriftAccessor() +class AuthUserRepository extends DatabaseAccessor with $AuthUserRepositoryMixin { + AuthUserRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future get(String id) async { final user = await _db.managers.authUserEntity.filter((user) => user.id.equals(id)).getSingleOrNull(); diff --git a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart index 173ec10b97..001e482f5c 100644 --- a/mobile/lib/infrastructure/repositories/user_metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/user_metadata.repository.dart @@ -3,9 +3,11 @@ import 'package:immich_mobile/domain/models/user_metadata.model.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -class DriftUserMetadataRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftUserMetadataRepository(this._db) : super(_db); +@DriftAccessor() +class UserMetadataRepository extends DatabaseAccessor { + UserMetadataRepository(super.attachedDatabase); + + Drift get _db => attachedDatabase; Future> getUserMetadata(String userId) { final query = _db.userMetadataEntity.select()..where((e) => e.userId.equals(userId)); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index d2fd945cbf..d94306475f 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -272,12 +272,16 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), builder: (context, child) => ImmichTranslationProvider( - translations: ImmichTranslations(submit: context.t.submit, password: context.t.password), + translations: ImmichTranslations( + submit: context.t.submit, + password: context.t.password, + undo: context.t.undo, + ), child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!), ), routerConfig: router.config( deepLinkBuilder: _deepLinkBuilder, - navigatorObservers: () => [AppNavigationObserver(ref: ref)], + navigatorObservers: () => [AppNavigationObserver(ref: ref), TransitioningRouteObserver()], ), ), ); diff --git a/mobile/lib/models/download/download_state.model.dart b/mobile/lib/models/download/download_state.model.dart index 7e47d60969..5f04fcdfd4 100644 --- a/mobile/lib/models/download/download_state.model.dart +++ b/mobile/lib/models/download/download_state.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:background_downloader/background_downloader.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; diff --git a/mobile/lib/models/download/livephotos_medatada.model.dart b/mobile/lib/models/download/livephotos_medatada.model.dart index 228ad707da..833a9ffca7 100644 --- a/mobile/lib/models/download/livephotos_medatada.model.dart +++ b/mobile/lib/models/download/livephotos_medatada.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; enum LivePhotosPart { video, image } diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 7e5e1b819e..03660204b9 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -194,7 +193,7 @@ class SearchFilter { String? language; String? assetId; List? tagIds; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -250,7 +249,7 @@ class SearchFilter { String? language, String? ocr, String? assetId, - Set? people, + Set? people, List? tagIds, SearchLocationFilter? location, SearchCameraFilter? camera, diff --git a/mobile/lib/models/server_info/server_version.model.dart b/mobile/lib/models/server_info/server_version.model.dart index 40f35a3cd0..cf16d4db0b 100644 --- a/mobile/lib/models/server_info/server_version.model.dart +++ b/mobile/lib/models/server_info/server_version.model.dart @@ -6,8 +6,4 @@ class ServerVersion extends SemVer { ServerVersion.fromDto(ServerVersionResponseDto dto) : super(major: dto.major, minor: dto.minor, patch: dto.patch_, prerelease: dto.prerelease); - - bool isAtLeast({int major = 0, int minor = 0, int patch = 0, int? prerelease}) { - return this >= SemVer(major: major, minor: minor, patch: patch, prerelease: prerelease); - } } diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index 0f9c3e4c29..3643020bc8 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -1,4 +1,3 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; import 'dart:io'; diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index ad2674b81b..a0e55e58de 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -316,12 +316,11 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final backupAlbums = ref.watch(backupAlbumProvider); + Widget buildSelectedAlbumName() { String text = context.t.backup_controller_page_backup_selected; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.selected) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.selected).toList(); if (albums.isNotEmpty) { for (var album in albums) { @@ -352,10 +351,7 @@ class _BackupAlbumSelectionCard extends ConsumerWidget { Widget buildExcludedAlbumName() { String text = context.t.backup_controller_page_excluded; - final albums = ref - .read(backupAlbumProvider) - .where((album) => album.backupSelection == BackupSelection.excluded) - .toList(); + final albums = backupAlbums.where((album) => album.backupSelection == BackupSelection.excluded).toList(); if (albums.isNotEmpty) { for (var album in albums) { diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart index 6993601c62..764cb5dcf1 100644 --- a/mobile/lib/pages/backup/drift_upload_detail.page.dart +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:path/path.dart' as path; @@ -530,7 +530,7 @@ class _CurrentUploadThumbnail extends ConsumerWidget { Future _getAsset(WidgetRef ref) async { try { - return await ref.read(localAssetRepository).getById(taskId); + return await ref.read(driftProvider).localAssetRepository.getById(taskId); } catch (e) { return null; } @@ -661,7 +661,7 @@ class FileDetailDialog extends ConsumerWidget { Future _getAssetDetails(WidgetRef ref, String localAssetId) async { try { - return await ref.read(localAssetRepository).getById(localAssetId); + return await ref.read(driftProvider).localAssetRepository.getById(localAssetId); } catch (e) { return null; } diff --git a/mobile/lib/pages/common/download_panel.dart b/mobile/lib/pages/common/download_panel.dart index 9ee5833b83..2cf7d037a3 100644 --- a/mobile/lib/pages/common/download_panel.dart +++ b/mobile/lib/pages/common/download_panel.dart @@ -64,6 +64,7 @@ class DownloadTaskTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isComplete = status == TaskStatus.complete; final progressPercent = (progress * 100).round(); String getStatusText() => switch (status) { @@ -88,9 +89,14 @@ class DownloadTaskTile extends StatelessWidget { leading: const Icon(Icons.video_file_outlined), title: Text(getStatusText(), style: context.textTheme.labelLarge), trailing: IconButton( - icon: Icon(Icons.close, color: context.colorScheme.onError), + icon: Icon( + isComplete ? Icons.download_done : Icons.close, + color: isComplete ? context.colorScheme.primary : context.colorScheme.onError, + ), onPressed: onCancelDownload, - style: ElevatedButton.styleFrom(backgroundColor: context.colorScheme.error.withAlpha(200)), + style: isComplete + ? null + : ElevatedButton.styleFrom(backgroundColor: context.colorScheme.error.withAlpha(200)), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile/lib/pages/common/headers_settings.page.dart b/mobile/lib/pages/common/headers_settings.page.dart index 24c5da1c10..c934ca6a46 100644 --- a/mobile/lib/pages/common/headers_settings.page.dart +++ b/mobile/lib/pages/common/headers_settings.page.dart @@ -132,6 +132,8 @@ class HeaderKeyValueSettings extends StatelessWidget { border: const OutlineInputBorder(), ), autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, onChanged: (headerKey) { header.key = headerKey; }, @@ -160,6 +162,8 @@ class HeaderKeyValueSettings extends StatelessWidget { border: const OutlineInputBorder(), ), autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, onChanged: (headerValue) { header.value = headerValue; }, diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart index 58528008ca..f8a8c51e17 100644 --- a/mobile/lib/pages/common/large_leading_tile.dart +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -54,7 +54,7 @@ class LargeLeadingTile extends StatelessWidget { ], ), ), - if (trailing != null) trailing!, + ?trailing, ], ), ), diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index f172de92a6..1623479adb 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/pages/search/paginated_search.provide import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; @@ -132,7 +131,6 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // Library page if (index == kLibraryTabIndex) { ref.invalidate(localAlbumProvider); - ref.invalidate(driftGetAllPeopleProvider); } ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/pages/search/map/map_location_picker.page.dart b/mobile/lib/pages/search/map/map_location_picker.page.dart index b1d79b5150..314f2aba7d 100644 --- a/mobile/lib/pages/search/map/map_location_picker.page.dart +++ b/mobile/lib/pages/search/map/map_location_picker.page.dart @@ -140,7 +140,7 @@ class _BottomBar extends StatelessWidget { const SizedBox(width: 15), ValueListenableBuilder( valueListenable: selectedLatLng, - builder: (_, value, __) => + builder: (_, value, _) => Text("${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}"), ), ], diff --git a/mobile/lib/presentation/actions/archive.action.dart b/mobile/lib/presentation/actions/archive.action.dart index f8e4a1e038..1e41411a3c 100644 --- a/mobile/lib/presentation/actions/archive.action.dart +++ b/mobile/lib/presentation/actions/archive.action.dart @@ -57,7 +57,8 @@ class ArchiveAction extends AssetActionBuilder { try { await assetService.update(assetIds, visibility: .some(shouldArchive ? .archive : .timeline)); - toastService.success(message); + Future undo() => assetService.update(assetIds, visibility: .some(shouldArchive ? .timeline : .archive)); + toastService.success(message, toast: .new(onUndo: undo)); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to update the archive status for assets"); diff --git a/mobile/lib/presentation/actions/delete.action.dart b/mobile/lib/presentation/actions/delete.action.dart index ea7423354a..7edac9aa42 100644 --- a/mobile/lib/presentation/actions/delete.action.dart +++ b/mobile/lib/presentation/actions/delete.action.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/cleanup.service.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; @@ -37,7 +38,8 @@ final _stateProvider = Provider.family.autoDispose<_State?, ActionSource>((ref, final trashEnabled = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash)); // Assets already in the trash or in the locked folder are deleted outright, irrespective of the server setting. - final trash = trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked); + final trash = + ownedRemote.isEmpty || (trashEnabled && !ownedRemote.every((asset) => asset.isTrashed || asset.isLocked)); return (localIds: localIds, remoteIds: ownedRemote.map((asset) => asset.id).toList(growable: false), trash: trash); }, dependencies: [assetsActionProvider]); @@ -66,15 +68,19 @@ class DeleteAction extends AssetActionBuilder { } final (:localIds, :remoteIds, :trash) = state; + final assetService = ref.read(assetServiceProvider); final toastService = ref.read(toastServiceProvider); final clearSelection = ref.read(clearSelectionProvider(source)); try { final String? message; + // Only trashing is reversible; a permanent delete and a device cleanup are not. + ToastOption? undo; if (remoteIds.isEmpty) { message = await _removeLocalAssets(context, ref, localIds); } else if (trash) { message = await _moveToTrash(context, ref, remoteIds, localIds); + undo = .new(onUndo: () => assetService.restoreTrash(remoteIds)); } else { message = await _deletePermanently(context, ref, remoteIds, localIds); } @@ -83,7 +89,7 @@ class DeleteAction extends AssetActionBuilder { return; } - toastService.success(message); + toastService.success(message, toast: undo); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to delete assets"); diff --git a/mobile/lib/presentation/actions/edit_asset.action.dart b/mobile/lib/presentation/actions/edit_asset.action.dart index 9b11032df9..e2b4993c3b 100644 --- a/mobile/lib/presentation/actions/edit_asset.action.dart +++ b/mobile/lib/presentation/actions/edit_asset.action.dart @@ -7,21 +7,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/pages/edit/editor.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/error_handler.dart'; -import 'package:immich_mobile/utils/semver.dart'; - -const _minimumServerVersion = SemVer(major: 2, minor: 6, patch: 0); final _stateProvider = Provider.family.autoDispose((ref, source) { - final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion >= _minimumServerVersion)); + final isSupported = ref.watch(serverInfoProvider.select((state) => state.serverVersion.supports(.assetEdits))); if (!isSupported) { return null; } @@ -50,7 +49,7 @@ class EditAssetAction extends AssetActionBuilder { try { // TODO(shenlong): Move all EXIF and Apply Edits logic onto the Route - final repository = ref.read(remoteAssetRepositoryProvider); + final repository = ref.read(driftProvider).remoteAssetRepository; final (edits, exif) = await (repository.getAssetEdits(asset.id), repository.getExif(asset.id)).wait; if (exif == null || !context.mounted) { return; diff --git a/mobile/lib/presentation/actions/edit_datetime.action.dart b/mobile/lib/presentation/actions/edit_datetime.action.dart index 31de23d1a7..4ca3ddbaf4 100644 --- a/mobile/lib/presentation/actions/edit_datetime.action.dart +++ b/mobile/lib/presentation/actions/edit_datetime.action.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/utils/timezone.dart'; @@ -46,7 +47,7 @@ class EditDateTimeAction extends AssetActionBuilder { } final (:assetIds, :origin) = state; - final remoteAssetRepository = ref.read(remoteAssetRepositoryProvider); + final remoteAssetRepository = ref.read(driftProvider).remoteAssetRepository; final clearSelection = ref.read(clearSelectionProvider(source)); try { diff --git a/mobile/lib/presentation/actions/edit_location.action.dart b/mobile/lib/presentation/actions/edit_location.action.dart index 5ce74a0f40..01318de999 100644 --- a/mobile/lib/presentation/actions/edit_location.action.dart +++ b/mobile/lib/presentation/actions/edit_location.action.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; import 'package:immich_mobile/utils/error_handler.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; @@ -46,7 +47,7 @@ class EditLocationAction extends AssetActionBuilder { } final (:assetIds, :origin) = state; - final remoteAssetRepository = ref.read(remoteAssetRepositoryProvider); + final remoteAssetRepository = ref.read(driftProvider).remoteAssetRepository; final clearSelection = ref.read(clearSelectionProvider(source)); try { diff --git a/mobile/lib/presentation/actions/lock.action.dart b/mobile/lib/presentation/actions/lock.action.dart index 3d090b712d..dad0d020a9 100644 --- a/mobile/lib/presentation/actions/lock.action.dart +++ b/mobile/lib/presentation/actions/lock.action.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/toast.provider.dart'; +import 'package:immich_mobile/services/toast.service.dart'; import 'package:immich_mobile/utils/error_handler.dart'; typedef _State = ({bool shouldLock, List assetIds, List localIds}); @@ -62,7 +63,11 @@ class LockAction extends AssetActionBuilder { // A locked asset still sits in the device gallery, so offer to remove the local copy. await assetService.deleteLocal(localIds); } - toastService.success(message); + // Unlocking is a sensitive action and requires an elevated session + final toast = shouldLock + ? null + : ToastOption(onUndo: () => assetService.update(assetIds, visibility: const .some(.locked))); + toastService.success(message, toast: toast); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to update the locked folder for assets"); diff --git a/mobile/lib/presentation/actions/restore.action.dart b/mobile/lib/presentation/actions/restore.action.dart index 0a1b707b39..1ce444e906 100644 --- a/mobile/lib/presentation/actions/restore.action.dart +++ b/mobile/lib/presentation/actions/restore.action.dart @@ -38,7 +38,7 @@ class RestoreAction extends AssetActionBuilder { try { await assetService.restoreTrash(assetIds); - toastService.success(message); + toastService.success(message, toast: .new(onUndo: () => assetService.trash(assetIds))); clearSelection(); } catch (error, stack) { handleError(error, stack: stack, description: "Failed to restore assets"); diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index b5a95156d8..ec6a260c84 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -58,9 +57,9 @@ class LocalMediaSummaryPage extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(context.t.local_media_summary)), body: Consumer( - builder: (ctx, ref, __) { + builder: (ctx, ref, _) { final db = ref.watch(driftProvider); - final albumsFuture = ref.watch(localAlbumRepository).getAll(); + final albumsFuture = db.localAlbumRepository.getAll(); return CustomScrollView( slivers: [ @@ -138,9 +137,9 @@ class RemoteMediaSummaryPage extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(context.t.remote_media_summary)), body: Consumer( - builder: (ctx, ref, __) { + builder: (ctx, ref, _) { final db = ref.watch(driftProvider); - final albumsFuture = ref.watch(remoteAlbumRepository).getAll(); + final albumsFuture = db.remoteAlbumRepository.getAll(); return CustomScrollView( slivers: [ diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 59cabf8daf..078b9cc325 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -32,6 +32,8 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { final userId = ref.watch(authProvider).userId; final activityEnabled = useState(album.isActivityEnabled); final isOwner = album.ownerId == userId; + final owner = isOwner ? ref.watch(currentUserProvider) : null; + final allUsers = isOwner ? null : ref.watch(driftUsersProvider); void showErrorMessage() { ContextHelper(context).pop(); @@ -141,7 +143,6 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { Widget buildOwnerInfo() { if (isOwner) { - final owner = ref.read(currentUserProvider); return ListTile( leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(), title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)), @@ -149,8 +150,11 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { trailing: Text(context.t.owner, style: context.textTheme.labelLarge), ); } else { - final usersProvider = ref.read(driftUsersProvider); - return usersProvider.maybeWhen( + if (allUsers == null) { + return const SizedBox(); + } + + return allUsers.maybeWhen( data: (users) { final user = users.firstWhereOrNull((u) => u.id == album.ownerId); diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index dd63ac93f5..c1b7c772b3 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -152,7 +152,7 @@ class _PeopleCollectionCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final people = ref.watch(driftGetAllPeopleProvider); + final people = ref.watch(getAllPeopleProvider); return LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/presentation/pages/drift_people_collection.page.dart b/mobile/lib/presentation/pages/drift_people_collection.page.dart index cb305395cc..92f1ff79ca 100644 --- a/mobile/lib/presentation/pages/drift_people_collection.page.dart +++ b/mobile/lib/presentation/pages/drift_people_collection.page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; @@ -33,7 +34,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState filtered; if (_search != null) { - people = people.where((person) { + filtered = people.where((person) { return person.name.toLowerCase().removeDiacritics().contains( _search!.toLowerCase().removeDiacritics(), ); }).toList(); + } else { + filtered = people; } return GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -79,9 +83,9 @@ class _DriftPeopleCollectionPageState extends ConsumerState { - late DriftPerson _person; + late Person _person; @override void initState() { diff --git a/mobile/lib/presentation/pages/drift_place.page.dart b/mobile/lib/presentation/pages/drift_place.page.dart index 68824e2752..0d33f2ab29 100644 --- a/mobile/lib/presentation/pages/drift_place.page.dart +++ b/mobile/lib/presentation/pages/drift_place.page.dart @@ -94,7 +94,7 @@ class _Map extends StatelessWidget { height: 200, width: context.width, child: MapThumbnail( - onTap: (_, __) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)), + onTap: (_, _) => context.pushRoute(DriftMapRoute(initialLocation: currentLocation)), zoom: 8, centre: currentLocation ?? const LatLng(21.44950, -157.91959), showAttribution: false, @@ -134,16 +134,19 @@ class _PlaceList extends ConsumerWidget { ), ), data: (places) { + final List<(String, String)> filtered; if (search.value != null) { - places = places.where((place) { + filtered = places.where((place) { return place.$1.toLowerCase().contains(search.value!.toLowerCase()); }).toList(); + } else { + filtered = places; } return SliverList.builder( - itemCount: places.length, + itemCount: filtered.length, itemBuilder: (context, index) { - final place = places[index]; + final place = filtered[index]; return _PlaceTile(place: place); }, ); diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart index 824d7d7c6b..2662c60a30 100644 --- a/mobile/lib/presentation/pages/drift_slideshow.page.dart +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -295,14 +296,7 @@ class _DriftSlideshowPageState extends ConsumerState with Si color: context.colorScheme.primary, ); } else { - return LinearProgressIndicator( - color: context.colorScheme.primary, - borderRadius: BorderRadius.zero, - minHeight: 5, - value: - ref.read(videoPlayerProvider(asset.id).select((s) => s.position)).inMilliseconds / - asset.duration.inMilliseconds, - ); + return _VideoProgressBar(asset: asset); } } @@ -374,25 +368,13 @@ class _DriftSlideshowPageState extends ConsumerState with Si builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)), ); } else { - final status = ref.read(videoPlayerProvider(asset.id).select((s) => s.status)); - final position = ref.read(videoPlayerProvider(asset.id)).position; - - if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { - unawaited(_nextPage()); - } else if (status == VideoPlaybackStatus.playing) { - unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); - } - - return PhotoView.customChild( - onTapUp: (_, _, _) => _onTapUp(), - disableScaleGestures: true, - filterQuality: FilterQuality.high, - initialScale: scale, - child: NativeVideoViewer( - asset: asset, - isCurrent: isCurrent, - image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), - ), + return _VideoChild( + asset: asset, + isCurrent: isCurrent, + scale: scale, + imageProvider: imageProvider, + onTapUp: _onTapUp, + onCompleted: _nextPage, ); } } @@ -477,6 +459,67 @@ class _DriftSlideshowPageState extends ConsumerState with Si } } +class _VideoChild extends ConsumerWidget { + final BaseAsset asset; + final bool isCurrent; + final PhotoViewComputedScale scale; + final ImageProvider imageProvider; + final VoidCallback onTapUp; + final VoidCallback onCompleted; + + const _VideoChild({ + required this.asset, + required this.isCurrent, + required this.scale, + required this.imageProvider, + required this.onTapUp, + required this.onCompleted, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen(videoPlayerProvider(asset.id).select((s) => s.status), (_, status) { + if (status == VideoPlaybackStatus.completed) { + if (isCurrent && ref.read(videoPlayerProvider(asset.id)).position.inMicroseconds > 0) { + onCompleted(); + } + } else if (status == VideoPlaybackStatus.playing) { + unawaited(ref.read(videoPlayerProvider(asset.id).notifier).setLoop(false)); + } + }); + + return PhotoView.customChild( + onTapUp: (_, _, _) => onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } +} + +class _VideoProgressBar extends ConsumerWidget { + final BaseAsset asset; + + const _VideoProgressBar({required this.asset}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final position = ref.watch(videoPlayerProvider(asset.id).select((s) => s.position)); + + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: BorderRadius.zero, + minHeight: 5, + value: position.inMilliseconds / asset.duration.inMilliseconds, + ); + } +} + /// Progress bar for image slides, driven by an explicit [AnimationController]. /// /// [TweenAnimationBuilder] creates its controller internally with the default diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart index 215a5d1071..c11d5a1fd6 100644 --- a/mobile/lib/presentation/pages/drift_user_selection.page.dart +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -52,6 +52,7 @@ class DriftUserSelectionPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue> suggestedShareUsers = ref.watch(driftUsersProvider); + final sharedUsers = ref.watch(remoteAlbumSharedUsersProvider(album.id)); final sharedUsersList = useState>({}); void addNewUsersHandler() { @@ -136,9 +137,6 @@ class DriftUserSelectionPage extends HookConsumerWidget { ), body: suggestedShareUsers.widgetWhen( onData: (users) { - // Get shared users for this album from the database - final sharedUsers = ref.read(remoteAlbumSharedUsersProvider(album.id)); - return sharedUsers.when( data: (albumSharedUsers) { // Filter out users that are already shared with this album and the owner diff --git a/mobile/lib/presentation/pages/feature_message/whats_new.page.dart b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart index fb82f59577..d9e4e5c6eb 100644 --- a/mobile/lib/presentation/pages/feature_message/whats_new.page.dart +++ b/mobile/lib/presentation/pages/feature_message/whats_new.page.dart @@ -17,7 +17,7 @@ class WhatsNewPage extends StatelessWidget { body: ListView.separated( padding: const EdgeInsets.only(top: 16, bottom: 64), itemCount: highlights.length, - separatorBuilder: (_, __) => const SizedBox(height: 24), + separatorBuilder: (_, _) => const SizedBox(height: 24), itemBuilder: (_, index) => _HighlightCard(highlight: highlights[index]), ), ); @@ -53,7 +53,7 @@ class _HighlightCard extends StatelessWidget { : Image.asset( highlight.image!, fit: BoxFit.contain, - errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(), + errorBuilder: (context, _, _) => const FeatureMessagePlaceholder(), ), ), ), diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 0c6b0d8ae4..e364e0d2f8 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -129,7 +129,7 @@ class DriftSearchPage extends HookConsumerWidget { void showPeoplePicker() { var people = filter.value.people; - void handleOnSelect(Set value) { + void handleOnSelect(Set value) { people = value; } @@ -742,9 +742,7 @@ class _SearchResultGrid extends ConsumerWidget { return false; } - Widget? _bottomWidget(BuildContext context, WidgetRef ref) { - final isLoading = ref.read(paginatedSearchProvider.select((s) => s.isLoading)); - + Widget? _bottomWidget(BuildContext context, {required bool isLoading, required bool hasMore}) { if (isLoading) { return const SliverFillRemaining( hasScrollBody: false, @@ -755,8 +753,6 @@ class _SearchResultGrid extends ConsumerWidget { ); } - final hasMore = ref.read(paginatedSearchProvider.select((s) => s.nextPage != null)); - if (hasMore) { return null; } @@ -778,6 +774,7 @@ class _SearchResultGrid extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final hasAssets = ref.watch(paginatedSearchProvider.select((s) => s.assets.isNotEmpty)); final isLoading = ref.watch(paginatedSearchProvider.select((s) => s.isLoading)); + final hasMore = ref.watch(paginatedSearchProvider.select((s) => s.nextPage != null)); if (!hasAssets && !isLoading) { return const _SearchNoResults(); @@ -807,7 +804,7 @@ class _SearchResultGrid extends ConsumerWidget { bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), snapToMonth: false, loadingWidget: const SizedBox.shrink(), - bottomSliverWidget: _bottomWidget(context, ref), + bottomSliverWidget: _bottomWidget(context, isLoading: isLoading, hasMore: hasMore), ), ), ), diff --git a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart index c281f6b166..796c4ce43a 100644 --- a/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart +++ b/mobile/lib/presentation/widgets/album/pending_uploads_banner.widget.dart @@ -214,7 +214,7 @@ class _PendingUploadsSheet extends ConsumerWidget { child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: pending.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), + separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (_, index) => _PendingUploadTile(entry: pending[index]), ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart index efd2ee00a1..0cb2ccd254 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart @@ -77,7 +77,7 @@ class AppearsInDetails extends ConsumerWidget { ); }, loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart index 739fe0a0d9..00c20f35bd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart @@ -30,7 +30,7 @@ class PeopleDetails extends ConsumerWidget { final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id)); - Future showNameEditModal(DriftPerson person) async { + Future showNameEditModal(Person person) async { await showDialog( context: context, useRootNavigator: false, @@ -96,7 +96,7 @@ class PeopleDetails extends ConsumerWidget { } class _Avatar extends StatelessWidget { - final DriftPerson person; + final Person person; final DateTime assetFileCreatedAt; final VoidCallback? onTap; final VoidCallback? onNameTap; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 7556505158..9c17d9a035 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -363,7 +363,7 @@ class _AssetPageState extends ConsumerState { onDragCancel: _onDragCancel, onTapUp: _onTapUp, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, __, ___) => SizedBox( + errorBuilder: (_, _, _) => SizedBox( width: size.width, height: size.height, child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), diff --git a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart index 576d4937b6..b86d7298d5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/ocr_overlay.widget.dart @@ -110,7 +110,7 @@ class _OcrOverlayState extends ConsumerState { ); }, loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), ); } } diff --git a/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart index 73dcd054da..dc89c10294 100644 --- a/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart +++ b/mobile/lib/presentation/widgets/feature_message/feature_message_dialog.widget.dart @@ -17,8 +17,8 @@ Future showFeatureMessageDialog(BuildContext context) { barrierLabel: context.t.whats_new, barrierColor: Colors.black.withValues(alpha: 0.55), transitionDuration: const Duration(milliseconds: 280), - pageBuilder: (_, __, ___) => const _FeatureMessageDialog(), - transitionBuilder: (_, animation, __, child) { + pageBuilder: (_, _, _) => const _FeatureMessageDialog(), + transitionBuilder: (_, animation, _, child) { final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic); return FadeTransition( opacity: animation, @@ -249,7 +249,7 @@ class _FeaturePage extends StatelessWidget { : Image.asset( highlight.image!, fit: BoxFit.contain, - errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(), + errorBuilder: (context, _, _) => const FeatureMessagePlaceholder(), ), ), ), diff --git a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart index 2bafdc0843..eb8ce1815a 100644 --- a/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_bottom_info.widget.dart @@ -1,5 +1,3 @@ -// ignore_for_file: require_trailing_commas - import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/events.model.dart'; diff --git a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart index 0b8a08a5d8..e56a21e021 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_birthday_modal.widget.dart @@ -11,7 +11,7 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:scroll_date_picker/scroll_date_picker.dart'; class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonBirthdayEditForm({super.key, required this.person}); @@ -30,14 +30,9 @@ class _DriftPersonNameEditFormState extends ConsumerState saveBirthday() async { try { - final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate); - - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } + final result = await ref.read(driftPeopleServiceProvider).updateBirthday(widget.person.id, _selectedDate); + if (result != 0 && mounted) { context.pop(_selectedDate); } } catch (error) { diff --git a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart index 3b0e62ab1a..4ae41bc640 100644 --- a/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart +++ b/mobile/lib/presentation/widgets/people/person_edit_name_modal.widget.dart @@ -9,7 +9,7 @@ import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DriftPersonNameEditForm extends ConsumerStatefulWidget { - final DriftPerson person; + final Person person; const DriftPersonNameEditForm({super.key, required this.person}); @@ -29,12 +29,7 @@ class _DriftPersonNameEditFormState extends ConsumerState onEdit(String personId, String newName) async { try { final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName); - if (result != 0) { - ref.invalidate(driftGetAllPeopleProvider); - if (!mounted) { - return; - } - + if (result != 0 && mounted) { context.pop(newName); } } catch (error) { diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart index eb081a1e6a..a2298313ac 100644 --- a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -38,7 +38,6 @@ class Scrubber extends ConsumerStatefulWidget { Scrubber({ super.key, - Key? scrollThumbKey, required this.layoutSegments, required this.timelineHeight, this.topPadding = 0, diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 9e65dcb72c..0387cbf597 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -26,6 +26,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; @@ -190,7 +191,14 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi // may be in a background tab. In either case, `handleStatusBarTap()` still fires // Make sure the timeline is the primary route before scrolling to the top final routeData = context.findAncestorWidgetOfExactType()?.routeData; - if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true) { + // The tap is generated async, so it can arrive after a route pop has started (due to a back button or similar) + // Check if route is alive and not exiting before taking action + final observers = Navigator.maybeOf(context)?.widget.observers ?? const []; + final isRouteTransitioning = observers.whereType().any( + (observer) => observer.hasTransitioningRoute, + ); + + if (ModalRoute.of(context)?.isCurrent == true && routeData?.isActive == true && !isRouteTransitioning) { _scrollToTop(); } } @@ -400,7 +408,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi return PopScope( canPop: !isMultiSelectEnabled, - onPopInvokedWithResult: (_, __) { + onPopInvokedWithResult: (_, _) { if (isMultiSelectEnabled) { ref.read(multiSelectProvider.notifier).reset(); } diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 2c4854bdb0..ba72ef60c5 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -14,9 +14,29 @@ class DownloadStateNotifier extends StateNotifier { taskProgress: {}, ), ) { + _downloadService.onImageDownloadStatus = _downloadStatusCallback; + _downloadService.onVideoDownloadStatus = _downloadStatusCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadStatusCallback; _downloadService.onTaskProgress = _taskProgressCallback; } + void _downloadStatusCallback(TaskStatusUpdate update) { + if (update.status == TaskStatus.canceled) { + return; + } + + final existing = state.taskProgress[update.task.taskId]; + if (existing == null) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({update.task.taskId: existing.copyWith(status: update.status)}), + ); + } + void _taskProgressCallback(TaskProgressUpdate update) { // Ignore if the task is canceled or completed if (update.progress == -2 || update.progress == -1) { diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart index 25a4204928..17d275ff51 100644 --- a/mobile/lib/providers/backup/backup_album.provider.dart +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -4,10 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/services/local_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; final backupAlbumProvider = StateNotifierProvider>( - (ref) => BackupAlbumNotifier(ref.watch(localAlbumServiceProvider)), + (ref) => BackupAlbumNotifier(LocalAlbumService(ref.watch(driftProvider).localAlbumRepository)), ); class BackupAlbumNotifier extends StateNotifier> { @@ -22,40 +22,25 @@ class BackupAlbumNotifier extends StateNotifier> { } Future selectAlbum(LocalAlbum album) async { - album = album.copyWith(backupSelection: BackupSelection.selected); - await _localAlbumService.update(album); + final selectedAlbum = album.copyWith(backupSelection: BackupSelection.selected); + await _localAlbumService.update(selectedAlbum); - state = state - .map( - (currentAlbum) => currentAlbum.id == album.id - ? currentAlbum.copyWith(backupSelection: BackupSelection.selected) - : currentAlbum, - ) - .toList(); + state = state.map((currentAlbum) => currentAlbum.id == selectedAlbum.id ? selectedAlbum : currentAlbum).toList(); } Future deselectAlbum(LocalAlbum album) async { - album = album.copyWith(backupSelection: BackupSelection.none); - await _localAlbumService.update(album); + final deselectedAlbum = album.copyWith(backupSelection: BackupSelection.none); + await _localAlbumService.update(deselectedAlbum); state = state - .map( - (currentAlbum) => - currentAlbum.id == album.id ? currentAlbum.copyWith(backupSelection: BackupSelection.none) : currentAlbum, - ) + .map((currentAlbum) => currentAlbum.id == deselectedAlbum.id ? deselectedAlbum : currentAlbum) .toList(); } Future excludeAlbum(LocalAlbum album) async { - album = album.copyWith(backupSelection: BackupSelection.excluded); - await _localAlbumService.update(album); + final excludedAlbum = album.copyWith(backupSelection: BackupSelection.excluded); + await _localAlbumService.update(excludedAlbum); - state = state - .map( - (currentAlbum) => currentAlbum.id == album.id - ? currentAlbum.copyWith(backupSelection: BackupSelection.excluded) - : currentAlbum, - ) - .toList(); + state = state.map((currentAlbum) => currentAlbum.id == excludedAlbum.id ? excludedAlbum : currentAlbum).toList(); } } diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index c3e1098e4b..99b84cf95f 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/services/background_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; @@ -302,5 +302,8 @@ final driftCandidateBackupAlbumInfoProvider = FutureProvider.autoDispose.family< ref, assetId, ) { - return ref.read(localAssetRepository).getSourceAlbums(assetId, backupSelection: BackupSelection.selected); + return ref + .read(driftProvider) + .localAssetRepository + .getSourceAlbums(assetId, backupSelection: BackupSelection.selected); }); diff --git a/mobile/lib/providers/infrastructure/album.provider.dart b/mobile/lib/providers/infrastructure/album.provider.dart index 379e7b3101..579cc17838 100644 --- a/mobile/lib/providers/infrastructure/album.provider.dart +++ b/mobile/lib/providers/infrastructure/album.provider.dart @@ -5,41 +5,27 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/services/local_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; -final localAlbumRepository = Provider( - (ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)), -); - -final localAlbumServiceProvider = Provider( - (ref) => LocalAlbumService(ref.watch(localAlbumRepository)), -); - final localAlbumProvider = FutureProvider>( - (ref) => LocalAlbumService(ref.watch(localAlbumRepository)) + (ref) => LocalAlbumService(ref.watch(driftProvider).localAlbumRepository) .getAll(sortBy: {SortLocalAlbumsBy.newestAsset}) .then((albums) => albums.where((album) => album.assetCount > 0).toList()), ); final localAlbumThumbnailProvider = FutureProvider.family( - (ref, albumId) => LocalAlbumService(ref.watch(localAlbumRepository)).getThumbnail(albumId), -); - -final remoteAlbumRepository = Provider( - (ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)), + (ref, albumId) => LocalAlbumService(ref.watch(driftProvider).localAlbumRepository).getThumbnail(albumId), ); final remoteAlbumServiceProvider = Provider( (ref) => RemoteAlbumService( - ref.watch(remoteAlbumRepository), + ref.watch(driftProvider).remoteAlbumRepository, ref.watch(driftAlbumApiRepositoryProvider), ref.watch(foregroundUploadServiceProvider), ), - dependencies: [remoteAlbumRepository], ); final remoteAlbumProvider = NotifierProvider( diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 8ca0ca0ec9..ea89d1b9fb 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -1,38 +1,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/remote_exif.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; -final localAssetRepository = Provider( - (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), -); - -final remoteAssetRepositoryProvider = Provider( - (ref) => RemoteAssetRepository(ref.watch(driftProvider)), -); - -final remoteExifRepositoryProvider = Provider((ref) => RemoteExifRepository(ref.watch(driftProvider))); - -final trashedLocalAssetRepository = Provider( - (ref) => DriftTrashedLocalAssetRepository(ref.watch(driftProvider)), -); - -final assetServiceProvider = Provider( - (ref) => AssetService( - remoteRepository: ref.watch(remoteAssetRepositoryProvider), - exifRepository: ref.watch(remoteExifRepositoryProvider), - localRepository: ref.watch(localAssetRepository), +final assetServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return AssetService( + remoteRepository: db.remoteAssetRepository, + exifRepository: db.remoteExifRepository, + localRepository: db.localAssetRepository, apiRepository: ref.watch(assetApiRepositoryProvider), mediaRepository: ref.watch(assetMediaRepositoryProvider), - trashedLocalRepository: ref.watch(trashedLocalAssetRepository), - ), -); + trashedLocalRepository: db.trashedLocalAssetRepository, + ); +}); final placesProvider = FutureProvider>((ref) { final assetService = ref.watch(assetServiceProvider); diff --git a/mobile/lib/providers/infrastructure/map.provider.dart b/mobile/lib/providers/infrastructure/map.provider.dart index d9d261521e..7f2fa5b40c 100644 --- a/mobile/lib/providers/infrastructure/map.provider.dart +++ b/mobile/lib/providers/infrastructure/map.provider.dart @@ -1,13 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/map.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final mapRepositoryProvider = Provider((ref) => DriftMapRepository(ref.watch(driftProvider))); - final mapServiceProvider = Provider( (ref) { final user = ref.watch(currentUserProvider); @@ -19,12 +16,11 @@ final mapServiceProvider = Provider( ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id]; - final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions()); + final mapFactory = MapFactory(mapRepository: ref.watch(driftProvider).mapRepository); + final mapService = mapFactory.remote(users, ref.watch(mapStateProvider).toOptions()); return mapService; }, // Empty dependencies to inform the framework that this provider // might be used in a ProviderScope dependencies: const [], ); - -final mapFactoryProvider = Provider((ref) => MapFactory(mapRepository: ref.watch(mapRepositoryProvider))); diff --git a/mobile/lib/providers/infrastructure/memory.provider.dart b/mobile/lib/providers/infrastructure/memory.provider.dart index 6917f15475..8c70c153ad 100644 --- a/mobile/lib/providers/infrastructure/memory.provider.dart +++ b/mobile/lib/providers/infrastructure/memory.provider.dart @@ -3,18 +3,9 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/services/memory.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final driftMemoryRepositoryProvider = Provider( - (ref) => DriftMemoryRepository(ref.watch(driftProvider)), -); - -final driftMemoryServiceProvider = Provider( - (ref) => DriftMemoryService(ref.watch(driftMemoryRepositoryProvider)), -); - final driftMemoryLaneProvider = FutureProvider.autoDispose>((ref) { final (userId, enabled) = ref.watch(currentUserProvider.select((user) => (user?.id, user?.memoryEnabled ?? true))); if (userId == null || !enabled) { @@ -26,7 +17,7 @@ final driftMemoryLaneProvider = FutureProvider.autoDispose>((r final timer = Timer(nextMidnight.difference(now) + const Duration(seconds: 5), ref.invalidateSelf); ref.onDispose(timer.cancel); - final service = ref.watch(driftMemoryServiceProvider); + final service = DriftMemoryService(ref.watch(driftProvider).memoryRepository); return service.getMemoryLane(userId); }); diff --git a/mobile/lib/providers/infrastructure/ocr.provider.dart b/mobile/lib/providers/infrastructure/ocr.provider.dart index 111f3952ce..20cfdbf5b2 100644 --- a/mobile/lib/providers/infrastructure/ocr.provider.dart +++ b/mobile/lib/providers/infrastructure/ocr.provider.dart @@ -1,14 +1,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/ocr.model.dart'; import 'package:immich_mobile/domain/services/ocr.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/ocr.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -final ocrRepositoryProvider = Provider((ref) => OcrRepository(ref.watch(driftProvider))); - -final ocrServiceProvider = Provider((ref) => OcrService(ref.watch(ocrRepositoryProvider))); - final ocrAssetProvider = FutureProvider.autoDispose.family?, String>((ref, assetId) async { - final service = ref.watch(ocrServiceProvider); + final service = OcrService(ref.watch(driftProvider).ocrRepository); return service.get(assetId); }); diff --git a/mobile/lib/providers/infrastructure/people.provider.dart b/mobile/lib/providers/infrastructure/people.provider.dart index 1939940975..3d8c674efa 100644 --- a/mobile/lib/providers/infrastructure/people.provider.dart +++ b/mobile/lib/providers/infrastructure/people.provider.dart @@ -1,26 +1,21 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/person.model.dart'; import 'package:immich_mobile/domain/services/people.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/people.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart'; import 'package:immich_mobile/repositories/person_api.repository.dart'; -final driftPeopleRepositoryProvider = Provider( - (ref) => DriftPeopleRepository(ref.watch(driftProvider)), -); - final driftPeopleServiceProvider = Provider( - (ref) => DriftPeopleService(ref.watch(driftPeopleRepositoryProvider), ref.watch(personApiRepositoryProvider)), + (ref) => DriftPeopleService(ref.watch(driftProvider).peopleRepository, ref.watch(personApiRepositoryProvider)), ); -final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { +final driftPeopleAssetProvider = FutureProvider.family, String>((ref, assetId) async { final service = ref.watch(driftPeopleServiceProvider); return service.getAssetPeople(assetId); }); -final driftGetAllPeopleProvider = FutureProvider>((ref) async { +final getAllPeopleProvider = StreamProvider>((ref) async* { final service = ref.watch(driftPeopleServiceProvider); final prefs = await ref.watch(userMetadataPreferencesProvider.future); - return service.getAllPeople(minFaces: prefs?.minimumFaces ?? 3); + yield* service.watch(minFaces: prefs?.minimumFaces ?? 3); }); diff --git a/mobile/lib/providers/infrastructure/stack.provider.dart b/mobile/lib/providers/infrastructure/stack.provider.dart deleted file mode 100644 index 0528fd0c91..0000000000 --- a/mobile/lib/providers/infrastructure/stack.provider.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/infrastructure/repositories/stack.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; - -final driftStackProvider = Provider((ref) => DriftStackRepository(ref.watch(driftProvider))); diff --git a/mobile/lib/providers/infrastructure/sync.provider.dart b/mobile/lib/providers/infrastructure/sync.provider.dart index 700b51f12d..618c6039ff 100644 --- a/mobile/lib/providers/infrastructure/sync.provider.dart +++ b/mobile/lib/providers/infrastructure/sync.provider.dart @@ -3,55 +3,50 @@ import 'package:immich_mobile/domain/services/hash.service.dart'; import 'package:immich_mobile/domain/services/local_sync.service.dart'; import 'package:immich_mobile/domain/services/sync_stream.service.dart'; import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/permission.repository.dart'; -final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider))); - -final syncStreamServiceProvider = Provider( - (ref) => SyncStreamService( +final syncStreamServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return SyncStreamService( syncApiRepository: ref.watch(syncApiRepositoryProvider), - syncStreamRepository: ref.watch(syncStreamRepositoryProvider), - localAssetRepository: ref.watch(localAssetRepository), - trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), + syncStreamRepository: db.syncStreamRepository, + localAssetRepository: db.localAssetRepository, + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.watch(assetMediaRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider), - syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider), + syncMigrationRepository: db.syncMigrationRepository, api: ref.watch(apiServiceProvider), cancellation: ref.watch(cancellationProvider), - ), -); + ); +}); final syncApiRepositoryProvider = Provider((ref) => SyncApiRepository(ref.watch(apiServiceProvider))); -final syncStreamRepositoryProvider = Provider((ref) => SyncStreamRepository(ref.watch(driftProvider))); - -final localSyncServiceProvider = Provider( - (ref) => LocalSyncService( - localAlbumRepository: ref.watch(localAlbumRepository), - localAssetRepository: ref.watch(localAssetRepository), - trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), +final localSyncServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return LocalSyncService( + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, + trashedLocalAssetRepository: db.trashedLocalAssetRepository, assetMediaRepository: ref.watch(assetMediaRepositoryProvider), permissionRepository: ref.watch(permissionRepositoryProvider), nativeSyncApi: ref.watch(nativeSyncApiProvider), cancellation: ref.watch(cancellationProvider), - ), -); + ); +}); -final hashServiceProvider = Provider( - (ref) => HashService( - localAlbumRepository: ref.watch(localAlbumRepository), - localAssetRepository: ref.watch(localAssetRepository), +final hashServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return HashService( + localAlbumRepository: db.localAlbumRepository, + localAssetRepository: db.localAssetRepository, nativeSyncApi: ref.watch(nativeSyncApiProvider), - trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository), + trashedLocalAssetRepository: db.trashedLocalAssetRepository, cancellation: ref.watch(cancellationProvider), - ), -); + ); +}); diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index e5ac1389e9..667b2624d7 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -1,16 +1,11 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final timelineRepositoryProvider = Provider( - (ref) => DriftTimelineRepository(ref.watch(driftProvider)), -); - final timelineArgsProvider = Provider.autoDispose( (ref) => throw UnimplementedError('Will be overridden through a ProviderScope.'), ); @@ -29,7 +24,7 @@ final timelineServiceProvider = Provider( final timelineFactoryProvider = Provider( (ref) => TimelineFactory( - timelineRepository: ref.watch(timelineRepositoryProvider), + timelineRepository: ref.watch(driftProvider).timelineRepository, settingsRepository: ref.watch(settingsProvider), ), ); @@ -43,7 +38,8 @@ final timelineUsersProvider = StreamProvider>((ref) { // Drift re-emits a fresh but content-identical list on unrelated table updates, // which would dispose and rebuild the timeline service mid-load return ref - .watch(timelineRepositoryProvider) + .watch(driftProvider) + .timelineRepository .watchTimelineUserIds(currentUserId) .distinct(const ListEquality().equals); }); diff --git a/mobile/lib/providers/infrastructure/trash_sync.provider.dart b/mobile/lib/providers/infrastructure/trash_sync.provider.dart index a783080f33..13e1424d75 100644 --- a/mobile/lib/providers/infrastructure/trash_sync.provider.dart +++ b/mobile/lib/providers/infrastructure/trash_sync.provider.dart @@ -1,11 +1,11 @@ import 'package:async/async.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; typedef TrashedAssetsCount = ({int total, int hashed}); final trashedAssetsCountProvider = StreamProvider((ref) { - final repo = ref.watch(trashedLocalAssetRepository); + final repo = ref.watch(driftProvider).trashedLocalAssetRepository; final total$ = repo.watchCount(); final hashed$ = repo.watchHashedCount(); return StreamZip([total$, hashed$]).map((values) => (total: values[0], hashed: values[1])); diff --git a/mobile/lib/providers/infrastructure/user.provider.dart b/mobile/lib/providers/infrastructure/user.provider.dart index 09f74db37d..a36082f31b 100644 --- a/mobile/lib/providers/infrastructure/user.provider.dart +++ b/mobile/lib/providers/infrastructure/user.provider.dart @@ -1,16 +1,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart'; -final userRepositoryProvider = Provider((ref) => UserRepository(ref.watch(driftProvider))); - final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(apiServiceProvider).usersApi)); final userServiceProvider = Provider( @@ -20,12 +16,7 @@ final userServiceProvider = Provider( ), ); -final partnerRepositoryProvider = Provider((ref) => PartnerRepository(ref.watch(driftProvider))); - -final partnerServiceProvider = Provider( - (ref) => PartnerService( - ref.watch(userRepositoryProvider), - ref.watch(partnerRepositoryProvider), - ref.watch(partnerApiRepositoryProvider), - ), -); +final partnerServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); + return PartnerService(db.userRepository, db.partnerRepository, ref.watch(partnerApiRepositoryProvider)); +}); diff --git a/mobile/lib/providers/infrastructure/user_metadata.provider.dart b/mobile/lib/providers/infrastructure/user_metadata.provider.dart index 357b43f4f6..dd29b41a44 100644 --- a/mobile/lib/providers/infrastructure/user_metadata.provider.dart +++ b/mobile/lib/providers/infrastructure/user_metadata.provider.dart @@ -1,16 +1,11 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user_metadata.model.dart'; -import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; -final userMetadataRepository = Provider( - (ref) => DriftUserMetadataRepository(ref.watch(driftProvider)), -); - final userMetadataProvider = FutureProvider>((ref) async { - final repository = ref.watch(userMetadataRepository); + final repository = ref.watch(driftProvider).userMetadataRepository; final user = ref.watch(currentUserProvider); if (user == null) { return []; diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart deleted file mode 100644 index 1bd58509f5..0000000000 --- a/mobile/lib/providers/search/people.provider.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/services/person.service.dart'; - -final getAllPeopleProvider = FutureProvider.autoDispose>((ref) async { - final PersonService personService = ref.read(personServiceProvider); - - final people = await personService.getAllPeople(); - - return people; -}); - -final updatePersonNameProvider = FutureProvider.autoDispose( - (ref) => (String personId, String updatedName) async { - final PersonService personService = ref.read(personServiceProvider); - final person = await personService.updateName(personId, updatedName); - - if (person != null && person.name == updatedName) { - ref.invalidate(getAllPeopleProvider); - return true; - } - return false; - }, -); diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index 855b8302a9..8504487860 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -28,6 +28,8 @@ class DownloadRepository { void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; + void Function(TaskProgressUpdate)? onTaskProgress; // #29900: `taskStatusCallback` is called before the DB has been updated, causing a race between the two Live Photo tasks @@ -49,6 +51,7 @@ class DownloadRepository { _downloader.registerCallbacks( group: kDownloadGroupLivePhoto, + taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index 158e3ac333..262868eea1 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -11,12 +11,7 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { - final dto = await checkNull(_api.getAllPeople()); - return dto.people.map(_toPerson).toList(); - } - - Future update(String id, {String? name, DateTime? birthday}) async { + Future update(String id, {String? name, DateTime? birthday}) async { final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day); final dto = PersonUpdateDto( name: name == null ? const Optional.absent() : Optional.present(name), @@ -26,12 +21,6 @@ class PersonApiRepository extends ApiRepository { return _toPerson(response); } - static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( - birthDate: dto.birthDate, - id: dto.id, - isHidden: dto.isHidden, - name: dto.name, - thumbnailPath: dto.thumbnailPath, - updatedAt: dto.updatedAt.orElse(null), - ); + static Person _toPerson(PersonResponseDto dto) => + .new(birthDate: dto.birthDate, id: dto.id, name: dto.name, updatedAt: dto.updatedAt.orElse(null)); } diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 1ebc9825f4..136684093c 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -95,23 +95,34 @@ class UploadRepository { required Completer? cancelToken, void Function(int bytes, int totalBytes)? onProgress, required String logContext, + Client? httpClient, }) async { final String savedEndpoint = Store.get(StoreKey.serverEndpoint); - final baseRequest = ProgressMultipartRequest( - 'POST', - Uri.parse('$savedEndpoint/assets'), - abortTrigger: cancelToken?.future, - onProgress: onProgress, - ); + + ProgressMultipartRequest buildRequest() { + final request = ProgressMultipartRequest( + 'POST', + Uri.parse('$savedEndpoint/assets'), + abortTrigger: cancelToken?.future, + onProgress: onProgress, + ); + request.fields.addAll(fields); + request.files.add(MultipartFile("assetData", file.openRead(), file.lengthSync(), filename: originalFileName)); + return request; + } try { - final fileStream = file.openRead(); - final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName); + final client = httpClient ?? NetworkRepository.client; + StreamedResponse response; + try { + response = await client.send(buildRequest()); + } on RequestAbortedException { + rethrow; + } on ClientException catch (error) { + logger.warning("Upload $logContext failed before a response, resending once: $error"); + response = await client.send(buildRequest()); + } - baseRequest.fields.addAll(fields); - baseRequest.files.add(assetRawUploadData); - - final response = await NetworkRepository.client.send(baseRequest); final responseBodyString = await response.stream.bytesToString(); if (![200, 201].contains(response.statusCode)) { diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index a7f6d532e9..90381cf49c 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -30,3 +30,22 @@ class AppNavigationObserver extends AutoRouterObserver { ref.invalidate(isAssetViewerOpenProvider); } } + +/// Tracks routes that are undergoing a pop transition +class TransitioningRouteObserver extends NavigatorObserver { + int _transitioningRoutes = 0; + + /// Whether a "popping" route is still on screen + bool get hasTransitioningRoute => _transitioningRoutes > 0; + + @override + void didPop(Route route, Route? previousRoute) { + if (route is! TransitionRoute) { + return; + } + + _transitioningRoutes += 1; + // Transition completed and route disposed + unawaited(route.completed.whenComplete(() => _transitioningRoutes -= 1)); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b9dc1ec4ec..baa2602492 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -72,7 +72,6 @@ import 'package:immich_mobile/presentation/pages/profile/profile_picture_crop.pa import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.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'; import 'package:immich_mobile/routing/duplicate_guard.dart'; import 'package:immich_mobile/routing/locked_guard.dart'; @@ -89,7 +88,6 @@ final appRouterProvider = Provider( (ref) => AppRouter( ref.watch(apiServiceProvider), ref.watch(authServiceProvider), - ref.watch(galleryPermissionNotifier.notifier), ref.watch(secureStorageServiceProvider), ref.watch(localAuthServiceProvider), ), @@ -104,7 +102,6 @@ class AppRouter extends RootStackRouter { AppRouter( ApiService apiService, AuthService authService, - GalleryPermissionNotifier galleryPermissionNotifier, SecureStorageService secureStorageService, LocalAuthService localAuthService, ) { @@ -158,7 +155,7 @@ class AppRouter extends RootStackRouter { customRouteBuilder: (context, child, page) => PageRouteBuilder( fullscreenDialog: page.fullscreenDialog, settings: page, - pageBuilder: (_, __, ___) => child, + pageBuilder: (_, _, _) => child, opaque: false, transitionsBuilder: TransitionsBuilders.fadeIn, ), diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index ec9b7cd446..fcfb96d988 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; final actionServiceProvider = Provider( - (ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(remoteAssetRepositoryProvider)), + (ref) => ActionService(ref.watch(assetApiRepositoryProvider), ref.watch(driftProvider).remoteAssetRepository), ); class ActionService { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index f5e4c5c33e..d1aec9cc86 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -113,12 +113,10 @@ class ApiService { } Future _isEndpointAvailable(String serverUrl) async { - if (!serverUrl.endsWith('/api')) { - serverUrl += '/api'; - } + final endpoint = serverUrl.endsWith('/api') ? serverUrl : '$serverUrl/api'; try { - setEndpoint(serverUrl); + setEndpoint(endpoint); await serverInfoApi.pingServer().timeout(const Duration(seconds: 5)); } on TimeoutException catch (_) { return false; diff --git a/mobile/lib/services/background_upload.service.dart b/mobile/lib/services/background_upload.service.dart index b2a134389f..b526cc4b4f 100644 --- a/mobile/lib/services/background_upload.service.dart +++ b/mobile/lib/services/background_upload.service.dart @@ -16,7 +16,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; @@ -29,11 +29,12 @@ import 'package:path/path.dart' as p; part 'background_upload.service.freezed.dart'; final backgroundUploadServiceProvider = Provider((ref) { + final db = ref.watch(driftProvider); final service = BackgroundUploadService( ref.watch(uploadRepositoryProvider), ref.watch(storageRepositoryProvider), - ref.watch(localAssetRepository), - ref.watch(backupRepositoryProvider), + db.localAssetRepository, + db.backupRepository, ref.watch(assetMediaRepositoryProvider), ); @@ -92,8 +93,8 @@ class BackgroundUploadService { final UploadRepository _uploadRepository; final StorageRepository _storageRepository; - final DriftLocalAssetRepository _localAssetRepository; - final DriftBackupRepository _backupRepository; + final LocalAssetRepository _localAssetRepository; + final BackupRepository _backupRepository; final AssetMediaRepository _assetMediaRepository; final Logger _logger = Logger('BackgroundUploadService'); @@ -378,7 +379,7 @@ class BackgroundUploadService { 'fileModifiedAt': modifiedAt.toUtc().toIso8601String(), 'isFavorite': isFavorite?.toString() ?? 'false', 'duration': '0', - if (fields != null) ...fields, + ...?fields, if (CurrentPlatform.isIOS && cloudId != null) 'metadata': jsonEncode([ RemoteAssetMetadataItem( diff --git a/mobile/lib/services/cleanup.service.dart b/mobile/lib/services/cleanup.service.dart index fca5584859..06cda2cf0d 100644 --- a/mobile/lib/services/cleanup.service.dart +++ b/mobile/lib/services/cleanup.service.dart @@ -2,17 +2,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; final cleanupServiceProvider = Provider((ref) { - return CleanupService(ref.watch(localAssetRepository), ref.watch(assetMediaRepositoryProvider)); + return CleanupService(ref.watch(driftProvider).localAssetRepository, ref.watch(assetMediaRepositoryProvider)); }); class CleanupService { static final int _deleteBatchSize = CurrentPlatform.isAndroid ? 2000 : 10000; - final DriftLocalAssetRepository _localAssetRepository; + final LocalAssetRepository _localAssetRepository; final AssetMediaRepository _assetMediaRepository; const CleanupService(this._localAssetRepository, this._assetMediaRepository); @@ -46,7 +46,7 @@ class CleanupService { final deletedIds = await _assetMediaRepository.deleteAll(batch); if (deletedIds.isNotEmpty) { - await _localAssetRepository.delete(deletedIds); + await _localAssetRepository.deleteAssets(deletedIds); deletedCount += deletedIds.length; } } diff --git a/mobile/lib/services/deep_link.service.dart b/mobile/lib/services/deep_link.service.dart index d5947ceb8f..738cb78721 100644 --- a/mobile/lib/services/deep_link.service.dart +++ b/mobile/lib/services/deep_link.service.dart @@ -10,7 +10,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider; -import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; @@ -21,7 +21,7 @@ final deepLinkServiceProvider = Provider( ref.watch(timelineFactoryProvider), ref.watch(beta_asset_provider.assetServiceProvider), ref.watch(remoteAlbumServiceProvider), - ref.watch(driftMemoryServiceProvider), + DriftMemoryService(ref.watch(driftProvider).memoryRepository), ref.watch(driftPeopleServiceProvider), ref.watch(currentUserProvider), ), @@ -51,7 +51,7 @@ class DeepLinkService { final queryParams = link.uri.queryParameters; return switch (intent) { - "memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''), + "memory" => await _buildMemoryDeepLink(queryParams['id']), "asset" => await _buildAssetDeepLink(queryParams['id'] ?? '', ref), "album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''), "people" => await _buildPeopleDeepLink(queryParams['id'] ?? ''), diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index f38b20cc21..09a56f0ce9 100644 --- a/mobile/lib/services/download.service.dart +++ b/mobile/lib/services/download.service.dart @@ -22,6 +22,7 @@ class DownloadService { final Logger _log = Logger("DownloadService"); void Function(TaskStatusUpdate)? onImageDownloadStatus; void Function(TaskStatusUpdate)? onVideoDownloadStatus; + void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus; void Function(TaskProgressUpdate)? onTaskProgress; /// Active Live Photo IDs undergoing saving @@ -30,6 +31,7 @@ class DownloadService { DownloadService(this._fileMediaRepository, this._downloadRepository) { _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback; _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback; + _downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback; _downloadRepository.onTaskProgress = _onTaskProgressCallback; _downloadRepository.onLivePhotoRecordComplete = _onLivePhotoRecordComplete; @@ -65,6 +67,10 @@ class DownloadService { onVideoDownloadStatus?.call(update); } + void _onLivePhotoDownloadCallback(TaskStatusUpdate update) { + onLivePhotoDownloadStatus?.call(update); + } + Future _onLivePhotoRecordComplete(TaskRecord record) async { final livePhotosId = LivePhotosMetadata.fromJson(record.task.metaData).id; await _saveLivePhotos(livePhotosId); diff --git a/mobile/lib/services/foreground_upload.service.dart b/mobile/lib/services/foreground_upload.service.dart index 88576bbb4d..ac42e2482f 100644 --- a/mobile/lib/services/foreground_upload.service.dart +++ b/mobile/lib/services/foreground_upload.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/platform/connectivity_api.g.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart'; @@ -39,7 +40,7 @@ final foregroundUploadServiceProvider = Provider((ref) { return ForegroundUploadService( ref.watch(uploadRepositoryProvider), ref.watch(storageRepositoryProvider), - ref.watch(backupRepositoryProvider), + ref.watch(driftProvider).backupRepository, ref.watch(connectivityApiProvider), ref.watch(assetMediaRepositoryProvider), ); @@ -61,7 +62,7 @@ class ForegroundUploadService { final UploadRepository _uploadRepository; final StorageRepository _storageRepository; - final DriftBackupRepository _backupRepository; + final BackupRepository _backupRepository; final ConnectivityApi _connectivityApi; final AssetMediaRepository _assetMediaRepository; final Logger _logger = Logger('ForegroundUploadService'); diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart deleted file mode 100644 index 0d589ea71d..0000000000 --- a/mobile/lib/services/person.service.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/person.model.dart'; -import 'package:immich_mobile/repositories/person_api.repository.dart'; -import 'package:logging/logging.dart'; - -final personServiceProvider = Provider.autoDispose( - (ref) => PersonService(ref.watch(personApiRepositoryProvider)), -); - -class PersonService { - final Logger _log = Logger("PersonService"); - final PersonApiRepository _personApiRepository; - PersonService(this._personApiRepository); - - Future> getAllPeople() async { - try { - return await _personApiRepository.getAll(); - } catch (error, stack) { - _log.severe("Error while fetching curated people", error, stack); - return []; - } - } - - Future updateName(String id, String name) async { - try { - return await _personApiRepository.update(id, name: name); - } catch (error, stack) { - _log.severe("Error while updating person name", error, stack); - } - return null; - } -} diff --git a/mobile/lib/services/toast.service.dart b/mobile/lib/services/toast.service.dart index 2b61a945ff..1cbb526351 100644 --- a/mobile/lib/services/toast.service.dart +++ b/mobile/lib/services/toast.service.dart @@ -13,14 +13,25 @@ class ToastService { const ToastService(); FutureOr success(String message, {ToastOption? toast}) { - snackbar.success(message, duration: toast?.timeout); + snackbar.success(message, duration: toast?.timeout, action: toast?.action); } FutureOr info(String message, {ToastOption? toast}) { - snackbar.info(message, duration: toast?.timeout); + snackbar.info(message, duration: toast?.timeout, action: toast?.action); } FutureOr error(String message, {ToastOption? toast}) { - snackbar.error(message, duration: toast?.timeout); + snackbar.error(message, duration: toast?.timeout, action: toast?.action); + } +} + +extension on ToastOption { + SnackbarAction? get action { + final onUndo = this.onUndo; + if (onUndo == null) { + return null; + } + + return SnackbarAction(onPressed: onUndo); } } diff --git a/mobile/lib/services/view_intent_asset_resolver.service.dart b/mobile/lib/services/view_intent_asset_resolver.service.dart index 7bda1bdc13..94296f5293 100644 --- a/mobile/lib/services/view_intent_asset_resolver.service.dart +++ b/mobile/lib/services/view_intent_asset_resolver.service.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/models/view_intent/view_intent_payload.extension.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:logging/logging.dart'; @@ -19,13 +19,13 @@ class ViewIntentResolvedAsset { final viewIntentAssetResolverProvider = Provider( (ref) => ViewIntentAssetResolver( - localAssetRepository: ref.read(localAssetRepository), + localAssetRepository: ref.read(driftProvider).localAssetRepository, timelineFactory: ref.read(timelineFactoryProvider), ), ); class ViewIntentAssetResolver { - final DriftLocalAssetRepository _localAssetRepository; + final LocalAssetRepository _localAssetRepository; final TimelineFactory _timelineFactory; static final Logger _logger = Logger('ViewIntentAssetResolver'); diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index efe6403559..e6116afe53 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -51,7 +51,7 @@ abstract final class Bootstrap { await configureSqliteCache(); final (db, updatePool) = await openSqliteConnectionWithUpdatePool(name: 'immich'); final drift = Drift.sqlite(db, updatePool); - final DriftStoreRepository storeRepo = DriftStoreRepository(drift); + final StoreRepository storeRepo = StoreRepository(drift); await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates); diff --git a/mobile/lib/utils/hooks/app_settings_update_hook.dart b/mobile/lib/utils/hooks/app_settings_update_hook.dart index 9d7cc4b064..6b7ba357f3 100644 --- a/mobile/lib/utils/hooks/app_settings_update_hook.dart +++ b/mobile/lib/utils/hooks/app_settings_update_hook.dart @@ -9,7 +9,7 @@ ValueNotifier useAppSettingsState(AppSettingsEnum key) { final notifier = useState(Store.get(key.storeKey, key.defaultValue)); // Listen to changes to the notifier and update app settings - useValueChanged(notifier.value, (_, __) => unawaited(Store.put(key.storeKey, notifier.value))); + useValueChanged(notifier.value, (_, _) => unawaited(Store.put(key.storeKey, notifier.value))); return notifier; } diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index e92b2afd12..7afd3c8276 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -35,12 +35,13 @@ final Map> openApiPatches = { }, 'UserResponseDto': {'profileChangedAt': _now}, 'AssetResponseDto': {'visibility': 'timeline', 'createdAt': _now, 'isEdited': false}, - 'UserAdminResponseDto': {'profileChangedAt': _now}, + 'UserAdminResponseDto': {'profileChangedAt': _now, 'clusterGroupId': ''}, 'LoginResponseDto': {'isOnboarded': false}, 'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false}, 'SyncAssetV1': {'isEdited': false}, 'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false}, 'MemoriesResponse': {'duration': 5}, + 'WorkflowResponseDto': {'logging': false}, }; void upgradeDto(dynamic value, String targetType) { diff --git a/mobile/lib/utils/people.utils.dart b/mobile/lib/utils/people.utils.dart index 8da5a5a3eb..bd2b607e0b 100644 --- a/mobile/lib/utils/people.utils.dart +++ b/mobile/lib/utils/people.utils.dart @@ -34,7 +34,7 @@ int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) { (referenceDate.day < birthDate.day ? 1 : 0); } -Future showNameEditModal(BuildContext context, DriftPerson person) { +Future showNameEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, @@ -44,7 +44,7 @@ Future showNameEditModal(BuildContext context, DriftPerson person) { ); } -Future showBirthdayEditModal(BuildContext context, DriftPerson person) { +Future showBirthdayEditModal(BuildContext context, Person person) { return showDialog( context: context, useRootNavigator: false, diff --git a/mobile/lib/widgets/asset_viewer/video_controls.dart b/mobile/lib/widgets/asset_viewer/video_controls.dart index 14c4daf5d8..b5678b8d9f 100644 --- a/mobile/lib/widgets/asset_viewer/video_controls.dart +++ b/mobile/lib/widgets/asset_viewer/video_controls.dart @@ -97,7 +97,7 @@ class _VideoControlsState extends ConsumerState { _hideTimer.reset(); } }); - ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset()); + ref.listen(_provider.select((v) => v.status), (_, _) => _hideTimer.reset()); final notifier = ref.watch(_provider.notifier); final isLoaded = duration != Duration.zero; diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 4c6b2a7211..b966601c31 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -73,7 +73,7 @@ class ImmichSliverAppBar extends ConsumerWidget { onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()), icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), ), - if (actions != null) ...actions!, + ...?actions, if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), const _ProfileIndicator(), const SizedBox(width: 8), @@ -177,7 +177,15 @@ class _BackupIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final indicatorIcon = _getBackupBadgeIcon(context, ref); + final backupEnabled = ref.watch(appConfigProvider.select((c) => c.backup.enabled)); + final hasError = ref.watch(driftBackupProvider.select((state) => state.error != BackupError.none)); + final isUploading = ref.watch(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); + final indicatorIcon = _getBackupBadgeIcon( + context, + backupEnabled: backupEnabled, + hasError: hasError, + isUploading: isUploading, + ); return IconButton( onPressed: () => context.pushRoute(const DriftBackupRoute()), @@ -192,12 +200,14 @@ class _BackupIndicator extends ConsumerWidget { ); } - Widget? _getBackupBadgeIcon(BuildContext context, WidgetRef ref) { - final backupEnabled = ref.read(appConfigProvider.select((c) => c.backup.enabled)); - final hasError = ref.read(driftBackupProvider.select((state) => state.error != BackupError.none)); + Widget? _getBackupBadgeIcon( + BuildContext context, { + required bool backupEnabled, + required bool hasError, + required bool isUploading, + }) { final isDarkTheme = context.isDarkTheme; final iconColor = isDarkTheme ? Colors.white : Colors.black; - final isUploading = ref.read(driftBackupProvider.select((state) => state.uploadItems.isNotEmpty)); if (!backupEnabled) { return _BadgeLabel( diff --git a/mobile/lib/widgets/common/person_sliver_app_bar.dart b/mobile/lib/widgets/common/person_sliver_app_bar.dart index 4b159447e6..56eb3c6094 100644 --- a/mobile/lib/widgets/common/person_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/person_sliver_app_bar.dart @@ -28,7 +28,7 @@ class PersonSliverAppBar extends ConsumerStatefulWidget { required this.onBirthdayTap, }); - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; final VoidCallback onShowOptions; @@ -137,7 +137,7 @@ class _MesmerizingSliverAppBarState extends ConsumerState { class _ExpandedBackground extends ConsumerStatefulWidget { final double scrollProgress; - final DriftPerson person; + final Person person; final VoidCallback onNameTap; final VoidCallback onBirthdayTap; diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 5185c5c318..aa9750a2a5 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -493,14 +493,12 @@ class LoginForm extends HookConsumerWidget { builder: (context, form) => Column( spacing: ImmichSpacing.md, children: [ - ImmichTextInput( + ImmichEmailInput( controller: emailController, label: context.t.email, hintText: context.t.login_form_email_hint, validator: _validateEmail, keyboardAction: TextInputAction.next, - keyboardType: TextInputType.emailAddress, - autofillHints: const [AutofillHints.email], onSubmit: (_) => passwordFocusNode.requestFocus(), ), ImmichPasswordInput( diff --git a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart index ec83d5085e..6b53a273a0 100644 --- a/mobile/lib/widgets/map/map_settings/map_theme_picker.dart +++ b/mobile/lib/widgets/map/map_settings/map_theme_picker.dart @@ -79,7 +79,7 @@ class _BorderedMapThumbnail extends StatelessWidget { child: MapThumbnail( zoom: 2, centre: const LatLng(47, 5), - onTap: (_, __) => onThemeChange(mode), + onTap: (_, _) => onThemeChange(mode), themeMode: mode, showAttribution: false, ), diff --git a/mobile/lib/widgets/map/map_theme_override.dart b/mobile/lib/widgets/map/map_theme_override.dart index dda96efeaf..93cbd8846f 100644 --- a/mobile/lib/widgets/map/map_theme_override.dart +++ b/mobile/lib/widgets/map/map_theme_override.dart @@ -69,7 +69,7 @@ class _MapThemeOverrideState extends ConsumerState with Widget final appTheme = ref.watch(immichThemeProvider); final locale = ref.watch(localeProvider); - useValueChanged(_theme, (_, __) { + useValueChanged(_theme, (_, _) { if (_theme == ThemeMode.system) { WidgetsBinding.instance.addObserver(this); } else { diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index aa33d18403..3c41b2ca19 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -128,8 +128,8 @@ class PhotoViewGallery extends StatefulWidget { /// The builder must return a [PhotoViewGalleryPageOptions]. const PhotoViewGallery.builder({ super.key, - required this.itemCount, - required this.builder, + required int this.itemCount, + required PhotoViewGalleryBuilder this.builder, this.loadingBuilder, this.backgroundDecoration, this.wantKeepAlive = false, @@ -145,9 +145,7 @@ class PhotoViewGallery extends StatefulWidget { this.customSize, this.allowImplicitScrolling = false, this.enablePanAlways = false, - }) : pageOptions = null, - assert(itemCount != null), - assert(builder != null); + }) : pageOptions = null; /// A list of options to describe the items in the gallery final List? pageOptions; @@ -352,9 +350,9 @@ class _PhotoViewGalleryState extends State { /// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant /// class PhotoViewGalleryPageOptions { - PhotoViewGalleryPageOptions({ + const PhotoViewGalleryPageOptions({ this.key, - required this.imageProvider, + required ImageProvider this.imageProvider, this.heroAttributes, this.semanticLabel, this.minScale, @@ -379,8 +377,7 @@ class PhotoViewGalleryPageOptions { this.disableGestures, this.errorBuilder, }) : child = null, - childSize = null, - assert(imageProvider != null); + childSize = null; const PhotoViewGalleryPageOptions.customChild({ this.key, diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index c230a65690..a8249b48f9 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -203,7 +203,7 @@ class PhotoViewCoreState extends State } else if (scaleState == PhotoViewScaleState.zoomedIn) { animateRotation(controller.rotation, 0); if (_shouldAllowPanRotate()) { - animatePosition(controller.position, Offset.zero); + animatePosition(controller.position, clampPosition()); } } diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 7b55e0e37e..8f408e043e 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -121,7 +121,6 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer { super.debugOwner, this.validateAxis, this.touchSlopFactor = 1, - PointerDeviceKind? kind, this.disableScaleGestures = false, }) : super(supportedDevices: null); final HitCornersDetector? hitDetector; diff --git a/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart b/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart index 3ca31cb8f9..27aa256b14 100644 --- a/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart +++ b/mobile/lib/widgets/photo_view/src/utils/ignorable_change_notifier.dart @@ -28,12 +28,12 @@ class IgnorableChangeNotifier extends ChangeNotifier { return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false); } - void addIgnorableListener(listener) { + void addIgnorableListener(VoidCallback listener) { assert(_debugAssertNotDisposed()); _ignorableListeners!.add(listener); } - void removeIgnorableListener(listener) { + void removeIgnorableListener(VoidCallback listener) { assert(_debugAssertNotDisposed()); _ignorableListeners!.remove(listener); } diff --git a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart index d120955250..c1ee1ab769 100644 --- a/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart +++ b/mobile/lib/widgets/photo_view/src/utils/photo_view_utils.dart @@ -32,12 +32,10 @@ class ScaleBoundaries { double get minScale { assert(_minScale is double || _minScale is PhotoViewComputedScale); if (_minScale == PhotoViewComputedScale.contained) { - return _scaleForContained(outerSize, childSize) * - (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as + return _scaleForContained(outerSize, childSize) * (_minScale as PhotoViewComputedScale).multiplier; } if (_minScale == PhotoViewComputedScale.covered) { - return _scaleForCovering(outerSize, childSize) * - (_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as + return _scaleForCovering(outerSize, childSize) * (_minScale as PhotoViewComputedScale).multiplier; } assert(_minScale >= 0.0); return _minScale; @@ -46,16 +44,16 @@ class ScaleBoundaries { double get maxScale { assert(_maxScale is double || _maxScale is PhotoViewComputedScale); if (_maxScale == PhotoViewComputedScale.contained) { - return (_scaleForContained(outerSize, childSize) * - (_maxScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier) - .clamp(minScale, double.infinity); + return (_scaleForContained(outerSize, childSize) * (_maxScale as PhotoViewComputedScale).multiplier).clamp( + minScale, + double.infinity, + ); } if (_maxScale == PhotoViewComputedScale.covered) { - return (_scaleForCovering(outerSize, childSize) * - (_maxScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier) - .clamp(minScale, double.infinity); + return (_scaleForCovering(outerSize, childSize) * (_maxScale as PhotoViewComputedScale).multiplier).clamp( + minScale, + double.infinity, + ); } return _maxScale.clamp(minScale, double.infinity); } @@ -63,14 +61,10 @@ class ScaleBoundaries { double get initialScale { assert(_initialScale is double || _initialScale is PhotoViewComputedScale); if (_initialScale == PhotoViewComputedScale.contained) { - return _scaleForContained(outerSize, childSize) * - (_initialScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier; + return _scaleForContained(outerSize, childSize) * (_initialScale as PhotoViewComputedScale).multiplier; } if (_initialScale == PhotoViewComputedScale.covered) { - return _scaleForCovering(outerSize, childSize) * - (_initialScale as PhotoViewComputedScale) // ignore: avoid_as - .multiplier; + return _scaleForCovering(outerSize, childSize) * (_initialScale as PhotoViewComputedScale).multiplier; } return _initialScale.clamp(minScale, maxScale); } diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index b4e73e97f9..ee738265b2 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -8,15 +8,15 @@ import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/people.provider.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/search_field.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget { const imageSize = 60.0; final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return Column( children: [ @@ -65,7 +65,7 @@ class PeoplePicker extends HookConsumerWidget { padding: const EdgeInsets.only(bottom: 2.0), child: LargeLeadingTile( title: Text( - person.name, + person.name.nullIfEmpty ?? context.t.no_name, style: context.textTheme.bodyLarge?.copyWith( fontSize: 20, fontWeight: FontWeight.w500, diff --git a/mobile/lib/widgets/settings/advanced_settings.dart b/mobile/lib/widgets/settings/advanced_settings.dart index 9e80f8715c..44a992345f 100644 --- a/mobile/lib/widgets/settings/advanced_settings.dart +++ b/mobile/lib/widgets/settings/advanced_settings.dart @@ -36,7 +36,7 @@ class AdvancedSettings extends HookConsumerWidget { final preferRemote = useState(ref.watch(appConfigProvider).image.preferRemote); useValueChanged( preferRemote.value, - (_, __) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)), + (_, _) => unawaited(ref.read(settingsProvider).write(.imagePreferRemote, preferRemote.value)), ); final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled); @@ -44,7 +44,7 @@ class AdvancedSettings extends HookConsumerWidget { useValueChanged( levelId.value, - (_, __) => unawaited(LogService.I.setLogLevel(Level.LEVELS[levelId.value].toLogLevel())), + (_, _) => unawaited(LogService.I.setLogLevel(Level.LEVELS[levelId.value].toLogLevel())), ); Future checkAndroidVersion() async { diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart index 6ecd76dd78..651cc43656 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_layout_settings.dart @@ -15,7 +15,7 @@ class LayoutSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tilesPerRow = useState(ref.watch(appConfigProvider.select((s) => s.timeline.tilesPerRow))); - useValueChanged(tilesPerRow.value, (_, __) { + useValueChanged(tilesPerRow.value, (_, _) { unawaited(ref.read(settingsProvider).write(.timelineTilesPerRow, tilesPerRow.value)); }); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart index d1b1e3d0f2..5b5917addc 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart @@ -15,7 +15,7 @@ class ImageViewerQualitySetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isOriginal = useState(ref.watch(appConfigProvider).image.loadOriginal); - useValueChanged(isOriginal.value, (_, __) { + useValueChanged(isOriginal.value, (_, _) { unawaited(ref.read(settingsProvider).write(.imageLoadOriginal, isOriginal.value)); }); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart index ed07028bcf..f8dc6b0e1b 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart @@ -14,7 +14,7 @@ class ImageViewerTapToNavigateSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tapToNavigate = useState(ref.watch(appConfigProvider).viewer.tapToNavigate); - useValueChanged(tapToNavigate.value, (_, __) { + useValueChanged(tapToNavigate.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerTapToNavigate, tapToNavigate.value)); }); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart index bc4d9a1235..72d1685d33 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart @@ -23,16 +23,16 @@ class SlideshowSettings extends HookConsumerWidget { final useLook = useState(slideshow.look); final useDirection = useState(slideshow.direction); - useValueChanged(useRepeat.value, (_, __) { + useValueChanged(useRepeat.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowRepeat, useRepeat.value)); }); - useValueChanged(useDuration.value, (_, __) { + useValueChanged(useDuration.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowDuration, useDuration.value)); }); - useValueChanged(useLook.value, (_, __) { + useValueChanged(useLook.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowLook, useLook.value)); }); - useValueChanged(useDirection.value, (_, __) { + useValueChanged(useDirection.value, (_, _) { unawaited(ref.read(settingsProvider).write(.slideshowDirection, useDirection.value)); }); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart index 2a575061ca..bb14d4b327 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/video_viewer_settings.dart @@ -18,13 +18,13 @@ class VideoViewerSettings extends HookConsumerWidget { final useLoopVideo = useState(viewer.loopVideo); final useOriginalVideo = useState(viewer.loadOriginalVideo); - useValueChanged(useAutoPlayVideo.value, (_, __) { + useValueChanged(useAutoPlayVideo.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerAutoPlayVideo, useAutoPlayVideo.value)); }); - useValueChanged(useLoopVideo.value, (_, __) { + useValueChanged(useLoopVideo.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerLoopVideo, useLoopVideo.value)); }); - useValueChanged(useOriginalVideo.value, (_, __) { + useValueChanged(useOriginalVideo.value, (_, _) { unawaited(ref.read(settingsProvider).write(.viewerLoadOriginalVideo, useOriginalVideo.value)); }); diff --git a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart index d2edef832e..d97be93ac4 100644 --- a/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart +++ b/mobile/lib/widgets/settings/beta_sync_settings/sync_status_and_actions.dart @@ -3,6 +3,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/server_capability.model.dart'; +import 'package:immich_mobile/domain/services/local_album.service.dart'; +import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; @@ -11,7 +14,6 @@ import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -150,7 +152,7 @@ class SyncStatusAndActions extends HookConsumerWidget { unawaited(ref.read(backgroundSyncProvider).syncRemote()); }, ), - if (CurrentPlatform.isIOS && serverVersion.isAtLeast(major: 2, minor: 5)) + if (CurrentPlatform.isIOS && serverVersion.supports(.cloudIdMetadata)) SettingListTile( title: 'Sync Cloud Ids', leading: const Icon(Icons.cloud_circle_rounded), @@ -217,10 +219,11 @@ class _SyncStatsCounts extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final db = ref.watch(driftProvider); final assetService = ref.watch(assetServiceProvider); - final localAlbumService = ref.watch(localAlbumServiceProvider); + final localAlbumService = LocalAlbumService(db.localAlbumRepository); final remoteAlbumService = ref.watch(remoteAlbumServiceProvider); - final memoryService = ref.watch(driftMemoryServiceProvider); + final memoryService = DriftMemoryService(db.memoryRepository); final appSettingsService = ref.watch(appSettingsServiceProvider); Future> loadCounts() async { diff --git a/mobile/lib/widgets/settings/free_up_space_settings.dart b/mobile/lib/widgets/settings/free_up_space_settings.dart index 5885601171..07dfe95b21 100644 --- a/mobile/lib/widgets/settings/free_up_space_settings.dart +++ b/mobile/lib/widgets/settings/free_up_space_settings.dart @@ -407,7 +407,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState { setState(() => _currentStep = CleanupStep.values[step]); } }, - controlsBuilder: (_, __) => const SizedBox.shrink(), + controlsBuilder: (_, _) => const SizedBox.shrink(), steps: [ // Step 1: Select Cutoff Date Step( diff --git a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart index 581bae4324..80b0b78fc2 100644 --- a/mobile/lib/widgets/settings/networking_settings/networking_settings.dart +++ b/mobile/lib/widgets/settings/networking_settings/networking_settings.dart @@ -21,7 +21,7 @@ class NetworkingSettings extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final currentEndpoint = getServerUrl(); final featureEnabled = useState(ref.watch(appConfigProvider).network.autoEndpointSwitching); - useValueChanged(featureEnabled.value, (_, __) { + useValueChanged(featureEnabled.value, (_, _) { unawaited(ref.read(settingsProvider).write(.networkAutoEndpointSwitching, featureEnabled.value)); }); diff --git a/mobile/lib/widgets/settings/settings_button_list_tile.dart b/mobile/lib/widgets/settings/settings_button_list_tile.dart index 0e8d75b22f..1e06c0b2b4 100644 --- a/mobile/lib/widgets/settings/settings_button_list_tile.dart +++ b/mobile/lib/widgets/settings/settings_button_list_tile.dart @@ -41,7 +41,7 @@ class SettingsButtonListTile extends StatelessWidget { subtileText!, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary), ), - if (subtitle != null) subtitle!, + ?subtitle, const SizedBox(height: 6), child ?? ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)), ], diff --git a/mobile/mise.toml b/mobile/mise.toml index 08a26c0c3b..d16578f142 100644 --- a/mobile/mise.toml +++ b/mobile/mise.toml @@ -1,7 +1,10 @@ [tools] -"aqua:flutter/flutter" = "3.44.9" java = "21.0.2" +[tools."aqua:flutter/flutter"] +version = "3.44.9" +postinstall = "bash {{config_root}}/ios/scripts/xcode_flutter_patch.sh" + [tools."github:CQLabs/homebrew-dcm"] version = "1.37.0" bin = "dcm" @@ -29,7 +32,7 @@ sources = [ ] outputs = { auto = true } run = [ - "dart run build_runner build", + "dart run build_runner build", "dart format lib/routing/router.gr.dart", ] depends = ["//:open-api-dart"] @@ -99,7 +102,11 @@ run = "dart run drift_dev make-migrations" alias = "install" description = "Install flutter dependencies" depends = ["//:open-api-dart"] -run = "flutter pub get" +run = [ + # This receives passed `--enforce-lockfile` args + "flutter pub get", + { task = "install:ios" }, +] [tasks."install:ci"] description = "Install flutter dependencies in CI" @@ -107,6 +114,18 @@ run = [ { task = "install", args = ["--enforce-lockfile"] }, ] +[tasks.checkout] +description = "Bring a checkout up to date after switching branches" +run = [ + { task = "install" }, + { task = "checkout:ios" }, + { task = "codegen" }, +] + +[tasks."checkout:ios"] +description = "Bring the iOS project up to date after switching branches" +run = "bash ./scripts/checkout_ios.sh" + [tasks.start] alias = "start" description = "Start flutter app" @@ -132,6 +151,19 @@ run = [ ] wait_for = ["//:i18n:format-fix"] +[tasks."install:ios"] +description = "Install CocoaPods dependencies" +hide = true +sources = ["ios/Podfile", "ios/Podfile.lock", ".flutter-plugins-dependencies"] +outputs = ["ios/Pods/Manifest.lock"] +run = ''' +echo "Running pod install" +if [ "$(uname)" != "Darwin" ]; then + exit 0 +fi +cd ios && pod install +''' + [tasks."analyze:dart"] description = "Run Dart analysis" hide = true diff --git a/mobile/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 8ea88135e5..85875717a3 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,6 +1,7 @@ export 'src/color_override.dart'; export 'src/components/close_button.dart'; export 'src/components/column_button.dart'; +export 'src/components/email_input.dart'; export 'src/components/form.dart'; export 'src/components/formatted_text.dart'; export 'src/components/icon_button.dart'; diff --git a/mobile/packages/ui/lib/src/components/email_input.dart b/mobile/packages/ui/lib/src/components/email_input.dart new file mode 100644 index 0000000000..27d79e9f0e --- /dev/null +++ b/mobile/packages/ui/lib/src/components/email_input.dart @@ -0,0 +1,26 @@ +import 'package:flutter/services.dart'; +import 'package:immich_ui/src/components/text_input.dart'; + +/// An ImmichTextInput customized for receiving email addresses +class ImmichEmailInput extends ImmichTextInput { + const ImmichEmailInput({ + super.key, + super.controller, + super.focusNode, + super.label, + super.hintText, + super.validator, + super.onSubmit, + super.keyboardAction, + super.suffixIcon, + super.enabled, + super.autofocus, + super.autovalidateMode, + }) : super( + keyboardType: .emailAddress, + autofillHints: const [AutofillHints.email], + autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, + ); +} diff --git a/mobile/packages/ui/lib/src/components/password_input.dart b/mobile/packages/ui/lib/src/components/password_input.dart index d08b3de99d..aa66d43b4d 100644 --- a/mobile/packages/ui/lib/src/components/password_input.dart +++ b/mobile/packages/ui/lib/src/components/password_input.dart @@ -47,6 +47,9 @@ class _ImmichPasswordInputState extends State { onSubmit: widget.onSubmit, keyboardAction: widget.keyboardAction, obscureText: !_visible, + autocorrect: false, + smartDashesType: .disabled, + smartQuotesType: .disabled, suffixIcon: IconButton( onPressed: _toggleVisibility, icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded), diff --git a/mobile/packages/ui/lib/src/previews/email_input.dart b/mobile/packages/ui/lib/src/previews/email_input.dart new file mode 100644 index 0000000000..b9d1d64ea0 --- /dev/null +++ b/mobile/packages/ui/lib/src/previews/email_input.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/components/email_input.dart'; +import 'package:immich_ui/src/previews.dart'; + +@ImmichPreview(group: 'EmailInput', name: 'Basic') +Widget previewEmailInput() => const _PreviewEmailInput(); + +class _PreviewEmailInput extends StatefulWidget { + const _PreviewEmailInput(); + + @override + State<_PreviewEmailInput> createState() => _PreviewEmailInputState(); +} + +class _PreviewEmailInputState extends State<_PreviewEmailInput> { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ImmichEmailInput(label: 'Email', hintText: 'user@immich.app', controller: _controller); + } +} diff --git a/mobile/packages/ui/lib/src/snackbar.dart b/mobile/packages/ui/lib/src/snackbar.dart index 1ede1124a8..4e3c818df3 100644 --- a/mobile/packages/ui/lib/src/snackbar.dart +++ b/mobile/packages/ui/lib/src/snackbar.dart @@ -1,8 +1,19 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:immich_ui/immich_ui.dart'; +import 'package:immich_ui/src/internal.dart'; final scaffoldMessengerKey = GlobalKey(); +class SnackbarAction { + // Optional label for the action button. Falls back to the localized "Undo" label when null + final String? label; + final FutureOr Function() onPressed; + + const SnackbarAction({this.label, required this.onPressed}); +} + class SnackbarManager { const SnackbarManager(); @@ -10,6 +21,7 @@ class SnackbarManager { String message, SnackbarType type, { Duration? duration, + SnackbarAction? action, }) { final messenger = scaffoldMessengerKey.currentState; final context = scaffoldMessengerKey.currentContext; @@ -19,10 +31,10 @@ class SnackbarManager { duration ??= const .new(seconds: 4); messenger.hideCurrentSnackBar(); - return messenger.showSnackBar(_build(context, message, type, duration)); + return messenger.showSnackBar(_build(context, message, type, duration, action)); } - SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration) { + SnackBar _build(BuildContext context, String message, SnackbarType type, Duration duration, SnackbarAction? action) { final theme = Theme.of(context); final colors = theme.extension() ?? ImmichColors.harmonized(theme.colorScheme); final (IconData icon, Color background, Color foreground) = switch (type) { @@ -31,11 +43,22 @@ class SnackbarManager { .error => (Icons.warning_rounded, colors.error, colors.onError), }; + SnackBarAction? snackAction; + if (action != null) { + snackAction = .new( + label: action.label ?? context.translations.undo, + onPressed: action.onPressed, + textColor: foreground, + ); + } + return SnackBar( behavior: .floating, backgroundColor: background, duration: duration, shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))), + // A snackbar carrying an action stays up until it is tapped by default; our actions are optional + persist: false, content: Row( children: [ Icon(icon, color: foreground, size: ImmichIconSize.sm), @@ -50,17 +73,27 @@ class SnackbarManager { ), ], ), + action: snackAction, ); } - ScaffoldFeatureController? info(String message, {Duration? duration}) => - show(message, .info, duration: duration); + ScaffoldFeatureController? info( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .info, duration: duration, action: action); - ScaffoldFeatureController? success(String message, {Duration? duration}) => - show(message, .success, duration: duration); + ScaffoldFeatureController? success( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .success, duration: duration, action: action); - ScaffoldFeatureController? error(String message, {Duration? duration}) => - show(message, .error, duration: duration); + ScaffoldFeatureController? error( + String message, { + Duration? duration, + SnackbarAction? action, + }) => show(message, .error, duration: duration, action: action); } const snackbar = SnackbarManager(); diff --git a/mobile/packages/ui/lib/src/translation.dart b/mobile/packages/ui/lib/src/translation.dart index cd51f74422..1bca33f4f1 100644 --- a/mobile/packages/ui/lib/src/translation.dart +++ b/mobile/packages/ui/lib/src/translation.dart @@ -1,27 +1,24 @@ import 'package:flutter/material.dart'; class ImmichTranslations { - late String submit; - late String password; + final String submit; + final String password; + final String undo; - ImmichTranslations({String? submit, String? password}) { - this.submit = submit ?? 'Submit'; - this.password = password ?? 'Password'; - } + const ImmichTranslations({String? submit, String? password, String? undo}) + : submit = submit ?? 'Submit', + password = password ?? 'Password', + undo = undo ?? 'Undo'; } class ImmichTranslationProvider extends InheritedWidget { final ImmichTranslations? translations; - const ImmichTranslationProvider({ - super.key, - this.translations, - required super.child, - }); + const ImmichTranslationProvider({super.key, this.translations, required super.child}); static ImmichTranslations of(BuildContext context) { final provider = context.dependOnInheritedWidgetOfExactType(); - return provider?.translations ?? ImmichTranslations(); + return provider?.translations ?? const .new(); } @override diff --git a/mobile/packages/ui/test/smart_punctuation_test.dart b/mobile/packages/ui/test/smart_punctuation_test.dart new file mode 100644 index 0000000000..93f1f7a92d --- /dev/null +++ b/mobile/packages/ui/test/smart_punctuation_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_ui/src/components/email_input.dart'; +import 'package:immich_ui/src/components/password_input.dart'; + +import 'test_utils.dart'; + +void main() { + EditableText editable(WidgetTester tester) => tester.widget(find.byType(EditableText)); + + testWidgets('ImmichEmailInput disables smart punctuation', (tester) async { + await tester.pumpTestWidget(const ImmichEmailInput()); + + expect(editable(tester).smartDashesType, SmartDashesType.disabled); + expect(editable(tester).smartQuotesType, SmartQuotesType.disabled); + }); + + testWidgets('ImmichPasswordInput disables smart punctuation', (tester) async { + await tester.pumpTestWidget(const ImmichPasswordInput()); + + expect(editable(tester).obscureText, isTrue); + expect(editable(tester).smartDashesType, SmartDashesType.disabled); + expect(editable(tester).smartQuotesType, SmartQuotesType.disabled); + + await tester.tap(find.byIcon(Icons.visibility_rounded)); + await tester.pump(); + + expect(editable(tester).obscureText, isFalse); + expect(editable(tester).smartDashesType, SmartDashesType.disabled); + expect(editable(tester).smartQuotesType, SmartQuotesType.disabled); + }); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index ac19481079..65cd03c3ae 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -69,10 +69,10 @@ packages: dependency: "direct main" description: name: background_downloader - sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3 + sha256: d27fcaf94f738032f1e6db098230799bf31ae7bf184f173fc9a752fcdae75c0e url: "https://pub.dev" source: hosted - version: "9.5.5" + version: "9.5.6" bonsoir: dependency: "direct overridden" description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.7" build_config: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.15.1" built_collection: dependency: transitive description: @@ -253,10 +253,10 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" + sha256: "25cebb79dfe304022550e0c3c893ae051ded07183d2607f7b88473cb3ade33cb" url: "https://pub.dev" source: hosted - version: "7.1.1" + version: "7.3.0" connectivity_plus_platform_interface: dependency: transitive description: @@ -512,10 +512,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_local_notifications: dependency: "direct main" description: @@ -634,10 +634,10 @@ packages: dependency: "direct main" description: name: flutter_udid - sha256: fc599671cbe8b328e509c961ec121880406ed994dde659cc9ece9c7503cd31c7 + sha256: f23555e9d34d5a7dc70fdfa3ab8915b401786879d278568b3c0e801af9a14d2e url: "https://pub.dev" source: hosted - version: "4.1.2" + version: "4.1.6" flutter_web_auth_2: dependency: "direct main" description: @@ -868,10 +868,10 @@ packages: dependency: "direct main" description: name: image_picker - sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.3" image_picker_android: dependency: transitive description: @@ -1016,10 +1016,10 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.1.0" local_auth: dependency: "direct main" description: @@ -1072,26 +1072,26 @@ packages: dependency: "direct main" description: name: maplibre_gl - sha256: b676f124a2fcf88c4dafedc7b462155e6396d61ce7af46354b4de11b45c9812f + sha256: "8b24dce6a050ba44779154c550e0318654ee12c572fa5fd1ad0c12e90d061356" url: "https://pub.dev" source: hosted - version: "0.26.2" + version: "0.27.0" maplibre_gl_platform_interface: dependency: transitive description: name: maplibre_gl_platform_interface - sha256: "1f0ca8a99f03fa9434618ee21f4e42dd615830a7dd973e632b11c73ffec993a8" + sha256: "8a1952b77ce841162fcd2601dc857f5b3f8eea46d34025b901ef803bb15d2adc" url: "https://pub.dev" source: hosted - version: "0.26.2" + version: "0.27.0" maplibre_gl_web: dependency: transitive description: name: maplibre_gl_web - sha256: bbf022f29ceef26d73f63e584819fbd02fbaf4eb1facf5234206c78936cf8f1c + sha256: c9c7bb1183cc9c6baafcef0b5b741420db869ca45e474ca80edf732168a286ee url: "https://pub.dev" source: hosted - version: "0.26.2" + version: "0.27.0" matcher: dependency: transitive description: @@ -1250,10 +1250,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -1656,18 +1656,18 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189" + sha256: "61c7930bebf32c552ac5808c91bf33802e66711c9216090d3b5579c9f862e80c" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.4.0" sqlite3_connection_pool: dependency: "direct main" description: name: sqlite3_connection_pool - sha256: "9d2b3b398b03c96743fd071521fc665be73c33c9cd5c56d87196baff8d8b4398" + sha256: ad51972d37bdf82929888e4c85e722bd9a0b5ff8ad389f89b1b00d19603c0d2d url: "https://pub.dev" source: hosted - version: "0.2.6" + version: "0.2.7" sqlite3_web: dependency: transitive description: @@ -1864,10 +1864,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_graphics: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 32e67de4f6..9bda1b20ff 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -11,10 +11,10 @@ environment: dependencies: async: ^2.13.1 auto_route: ^11.1.0 - background_downloader: ^9.5.4 + background_downloader: ^9.5.6 cast: ^2.1.0 collection: ^1.19.1 - connectivity_plus: ^7.0.0 + connectivity_plus: ^7.3.0 crop_image: ^1.0.17 crypto: ^3.0.7 device_info_plus: ^12.4.0 @@ -31,20 +31,20 @@ dependencies: flutter_local_notifications: ^17.2.4 flutter_secure_storage: ^9.2.4 flutter_svg: ^2.2.4 - flutter_udid: ^4.1.2 + flutter_udid: ^4.1.6 flutter_web_auth_2: ^5.0.2 fluttertoast: ^8.2.14 geolocator: ^14.0.2 home_widget: ^0.9.3 hooks_riverpod: ^2.6.1 http: ^1.6.0 - image_picker: ^1.2.1 + image_picker: ^1.2.3 immich_ui: path: './packages/ui' intl: ^0.20.2 local_auth: ^2.3.0 logging: ^1.3.0 - maplibre_gl: ^0.26.0 + maplibre_gl: ^0.27.0 native_video_player: git: url: https://github.com/immich-app/native_video_player @@ -55,7 +55,7 @@ dependencies: path: generated/openapi package_info_plus: ^8.3.1 path: ^1.9.1 - path_provider: ^2.1.5 + path_provider: ^2.1.6 path_provider_foundation: ^2.6.0 permission_handler: ^11.4.0 photo_manager: 3.9.0 @@ -67,13 +67,13 @@ dependencies: share_plus: ^10.1.4 sliver_tools: ^0.2.12 stream_transform: ^2.1.1 - sqlite3: ^3.3.2 + sqlite3: ^3.4.0 sqlite_async: 0.14.2 - sqlite3_connection_pool: ^0.2.6 + sqlite3_connection_pool: ^0.2.7 thumbhash: 0.1.0+1 timezone: ^0.9.4 url_launcher: ^6.3.2 - uuid: ^4.5.3 + uuid: ^4.6.0 wakelock_plus: ^1.3.3 worker_manager: ^7.2.9 web_socket: ^1.0.1 @@ -95,13 +95,13 @@ dependencies: dev_dependencies: auto_route_generator: ^10.5.0 - build_runner: ^2.13.1 + build_runner: ^2.15.1 # Drift generator drift_dev: ^2.34.0 fake_async: ^1.3.3 file: ^7.0.1 # for MemoryFileSystem flutter_launcher_icons: ^0.14.4 - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter_native_splash: ^2.4.7 flutter_test: sdk: flutter diff --git a/mobile/scripts/checkout_ios.sh b/mobile/scripts/checkout_ios.sh new file mode 100644 index 0000000000..74706e0d23 --- /dev/null +++ b/mobile/scripts/checkout_ios.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Reconciles the iOS project with the current Flutter setup +# +# `flutter build ios --config-only` is relatively expensive, so we attempt to avoid running it when possible + +set -euo pipefail + +stamp="build/ios_checkout.stamp" + +# All tracked files that can invalidate the Xcode + Flutter build state +inputs_digest() { + git ls-files -z ios pubspec.yaml pubspec.lock mise.toml mise.lock \ + | xargs -0 shasum \ + | shasum \ + | cut -d ' ' -f 1 +} + +# Echoes why the iOS project needs reconciling, or returns 1 if it does not. +# The first three cases can happen with no tracked file having changed at all, +# so comparing the digest alone is not enough. +staleness() { + local flutter_root + flutter_root="$(sed -n 's/^FLUTTER_ROOT=//p' ios/Flutter/Generated.xcconfig 2>/dev/null || true)" + + if [[ -z $flutter_root ]]; then + echo "misconfigured FLUTTER_ROOT" + elif [[ ! -d $flutter_root ]]; then + echo "the Flutter SDK has changed" + elif ! cmp -s ios/Podfile.lock ios/Pods/Manifest.lock; then + # This is also done by Xcode's "Check Pods Manifest.lock" build phase + echo "Cocoapods out of sync" + elif [[ ! -f $stamp ]]; then + echo "first checkout" + elif [[ $(<"$stamp") != "$(inputs_digest)" ]]; then + echo "tracked build state has changed" + else + return 1 + fi +} + +[[ $(uname) == Darwin ]] || exit 0 + +if ! reason="$(staleness)"; then + exit 0 +fi + +echo "Reconciling iOS project: $reason" +flutter build ios --config-only --debug + +# Save tracked files manifest +mkdir -p "$(dirname "$stamp")" +inputs_digest > "$stamp" diff --git a/mobile/test/domain/repositories/sync_stream_repository_test.dart b/mobile/test/domain/repositories/sync_stream_repository_test.dart index bd47f63da5..13da161149 100644 --- a/mobile/test/domain/repositories/sync_stream_repository_test.dart +++ b/mobile/test/domain/repositories/sync_stream_repository_test.dart @@ -3,6 +3,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; @@ -25,17 +26,18 @@ SyncAssetV1 _createAsset({ required String id, required String checksum, required String fileName, - String ownerId = 'user-1', int? width, int? height, + String? libraryId, + bool isFavorite = false, }) { return SyncAssetV1( id: id, checksum: checksum, originalFileName: fileName, type: AssetTypeEnum.IMAGE, - ownerId: ownerId, - isFavorite: false, + ownerId: 'user-1', + isFavorite: isFavorite, fileCreatedAt: DateTime(2024, 1, 1), fileModifiedAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1), @@ -45,6 +47,31 @@ SyncAssetV1 _createAsset({ height: height, deletedAt: null, duration: null, + libraryId: libraryId, + livePhotoVideoId: null, + stackId: null, + thumbhash: null, + isEdited: false, + ); +} + +SyncAssetV2 _createAssetV2({required String id, required String checksum, required String fileName}) { + return SyncAssetV2( + id: id, + checksum: checksum, + originalFileName: fileName, + type: AssetTypeEnum.IMAGE, + ownerId: 'user-1', + isFavorite: false, + fileCreatedAt: DateTime(2024, 1, 1), + fileModifiedAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024, 1, 1), + localDateTime: DateTime(2024, 1, 1), + visibility: AssetVisibility.timeline, + width: null, + height: null, + deletedAt: null, + duration: 0, libraryId: null, livePhotoVideoId: null, stackId: null, @@ -240,4 +267,100 @@ void main() { expect(after.backupSelection, equals(BackupSelection.none)); }); }); + + group('SyncStreamRepository - updateAssets upsert dedupe (#22522 #27186)', () { + Future seedExif(String assetId) => + db.remoteExifEntity.insertOne(RemoteExifEntityCompanion.insert(assetId: assetId)); + + Future exifExists(String assetId) async { + final rows = await (db.remoteExifEntity.select()..where((t) => t.assetId.equals(assetId))).get(); + return rows.isNotEmpty; + } + + test('same-id update keeps the child row and updates fields', () async { + await sut.updateUsersV1([_createUser()]); + final asset = _createAsset(id: 'a', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([asset]); + await seedExif(asset.id); + + final renamed = _createAsset(id: asset.id, checksum: asset.checksum, fileName: 'renamed.jpg', isFavorite: true); + await sut.updateAssetsV1([renamed]); + + expect(await exifExists(asset.id), isTrue, reason: 'DO UPDATE keeps the row, the child survives'); + final row = await (db.remoteAssetEntity.select()..where((t) => t.id.equals(asset.id))).getSingle(); + expect(row.name, renamed.originalFileName); + expect(row.isFavorite, isTrue); + }); + + test('reupload with a new id replaces the stale row and cascades its child', () async { + await sut.updateUsersV1([_createUser()]); + final stale = _createAsset(id: 'stale', checksum: 'AAA', fileName: 'photo.jpg'); + await sut.updateAssetsV1([stale]); + await seedExif(stale.id); + + final fresh = _createAsset(id: 'fresh', checksum: stale.checksum, fileName: stale.originalFileName); + await sut.updateAssetsV1([fresh]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(1), reason: 'no 2067, stale row replaced away'); + expect(rows.single.id, fresh.id); + expect(await exifExists(stale.id), isFalse, reason: 'the stale child cascades with the replaced row'); + + // same scenario through V2 + final staleV2 = _createAssetV2(id: 'stale2', checksum: 'BBB', fileName: 'photo2.jpg'); + await sut.updateAssetsV2([staleV2]); + await seedExif(staleV2.id); + final freshV2 = _createAssetV2(id: 'fresh2', checksum: staleV2.checksum, fileName: staleV2.originalFileName); + await sut.updateAssetsV2([freshV2]); + + final rows2 = await db.remoteAssetEntity.select().get(); + expect(rows2.map((r) => r.id), containsAll([fresh.id, freshV2.id])); + expect(await exifExists(staleV2.id), isFalse); + }); + + test('library variant replaces only the matching library row', () async { + await sut.updateUsersV1([_createUser()]); + final staleLib = _createAsset(id: 'stale-lib', checksum: 'AAA', fileName: 'photo.jpg', libraryId: 'lib-1'); + final keepNull = _createAsset(id: 'keep-null', checksum: staleLib.checksum, fileName: staleLib.originalFileName); + await sut.updateAssetsV1([staleLib, keepNull]); + + final freshLib = _createAsset( + id: 'fresh-lib', + checksum: staleLib.checksum, + fileName: staleLib.originalFileName, + libraryId: staleLib.libraryId, + ); + await sut.updateAssetsV1([freshLib]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows.map((r) => r.id).toSet(), { + freshLib.id, + keepNull.id, + }, reason: 'library NULL and NOT NULL match different partial indexes'); + }); + + test('batch-internal duplicates keep the last payload asset', () async { + await sut.updateUsersV1([_createUser()]); + final first = _createAsset(id: 'first-id', checksum: 'AAA', fileName: 'photo.jpg'); + final last = _createAsset(id: 'last-id', checksum: first.checksum, fileName: first.originalFileName); + final firstLib = _createAsset( + id: 'first-lib', + checksum: 'BBB', + fileName: first.originalFileName, + libraryId: 'lib-1', + ); + final lastLib = _createAsset( + id: 'last-lib', + checksum: firstLib.checksum, + fileName: firstLib.originalFileName, + libraryId: firstLib.libraryId, + ); + + await sut.updateAssetsV1([first, last, firstLib, lastLib]); + + final rows = await db.remoteAssetEntity.select().get(); + expect(rows, hasLength(2), reason: 'REPLACE makes batch-internal duplicates last-wins, no crash'); + expect(rows.map((r) => r.id).toSet(), {last.id, lastLib.id}); + }); + }); } diff --git a/mobile/test/domain/services/local_sync_service_test.dart b/mobile/test/domain/services/local_sync_service_test.dart index 14277709da..2cbabd77e9 100644 --- a/mobile/test/domain/services/local_sync_service_test.dart +++ b/mobile/test/domain/services/local_sync_service_test.dart @@ -23,9 +23,9 @@ import '../../service.mocks.dart'; void main() { late LocalSyncService sut; - late DriftLocalAlbumRepository mockLocalAlbumRepository; - late DriftLocalAssetRepository mockLocalAssetRepository; - late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository; + late LocalAlbumRepository mockLocalAlbumRepository; + late LocalAssetRepository mockLocalAssetRepository; + late TrashedLocalAssetRepository mockTrashedLocalAssetRepository; late AssetMediaRepository mockAssetMediaRepository; late MockPermissionRepository mockPermissionRepository; late MockNativeSyncApi mockNativeSyncApi; @@ -36,7 +36,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/domain/services/store_service_test.dart b/mobile/test/domain/services/store_service_test.dart index bb439b3d72..adab8863c1 100644 --- a/mobile/test/domain/services/store_service_test.dart +++ b/mobile/test/domain/services/store_service_test.dart @@ -14,12 +14,12 @@ const _kVersion = 2; void main() { late StoreService sut; - late DriftStoreRepository mockDriftStoreRepo; + late StoreRepository mockDriftStoreRepo; late StreamController>> controller; setUp(() async { controller = StreamController>>.broadcast(); - mockDriftStoreRepo = MockDriftStoreRepository(); + mockDriftStoreRepo = MockStoreRepository(); // For generics, we need to provide fallback to each concrete type to avoid runtime errors registerFallbackValue(StoreKey.accessToken); registerFallbackValue(StoreKey.version); @@ -124,12 +124,12 @@ void main() { group('Store Service delete:', () { setUp(() { - when(() => mockDriftStoreRepo.delete(any>())).thenAnswer((_) async => true); + when(() => mockDriftStoreRepo.deleteValue(any>())).thenAnswer((_) async => true); }); test('Removes the value from the DB', () async { await sut.delete(StoreKey.accessToken); - verify(() => mockDriftStoreRepo.delete(StoreKey.accessToken)).called(1); + verify(() => mockDriftStoreRepo.deleteValue(StoreKey.accessToken)).called(1); }); test('Removes the value from the cache', () async { diff --git a/mobile/test/domain/services/sync_linked_album_service_test.dart b/mobile/test/domain/services/sync_linked_album_service_test.dart index e5f4c3b5dc..60b230ba65 100644 --- a/mobile/test/domain/services/sync_linked_album_service_test.dart +++ b/mobile/test/domain/services/sync_linked_album_service_test.dart @@ -1,9 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/sync_linked_album.service.dart'; -import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; +import 'package:mocktail/mocktail.dart'; import '../../infrastructure/repository.mock.dart'; import '../../service.mocks.dart'; @@ -13,10 +14,12 @@ void main() { // alone, i.e. the root (main) isolate, where cancellationProvider has no // override and throws if read. The UI reads this provider here. ProviderContainer rootContainer() { + final drift = MockDrift(); + when(() => drift.localAlbumRepository).thenReturn(MockLocalAlbumRepository()); + when(() => drift.remoteAlbumRepository).thenReturn(MockRemoteAlbumRepository()); final container = ProviderContainer( overrides: [ - localAlbumRepository.overrideWithValue(MockLocalAlbumRepository()), - remoteAlbumRepository.overrideWithValue(MockRemoteAlbumRepository()), + driftProvider.overrideWithValue(drift), driftAlbumApiRepositoryProvider.overrideWithValue(MockDriftAlbumApiRepository()), storeServiceProvider.overrideWithValue(MockStoreService()), ], diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index 5cd4104f91..58b96a2fc6 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -40,8 +40,8 @@ void main() { late SyncStreamService sut; late SyncStreamRepository mockSyncStreamRepo; late SyncApiRepository mockSyncApiRepo; - late DriftLocalAssetRepository mockLocalAssetRepo; - late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo; + late LocalAssetRepository mockLocalAssetRepo; + late TrashedLocalAssetRepository mockTrashedLocalAssetRepo; late AssetMediaRepository mockAssetMediaRepo; late MockPermissionRepository mockPermissionRepo; late MockApiService mockApi; @@ -60,7 +60,7 @@ void main() { registerFallbackValue(const SemVer(major: 2, minor: 5, patch: 0)); db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/drift/main/migration_test.dart b/mobile/test/drift/main/migration_test.dart index 74467492ae..ce3f644b98 100644 --- a/mobile/test/drift/main/migration_test.dart +++ b/mobile/test/drift/main/migration_test.dart @@ -1,5 +1,5 @@ // dart format width=80 -// ignore_for_file: unused_local_variable, unused_import +// ignore_for_file: unused_import import 'package:drift/drift.dart'; import 'package:drift_dev/api/migrations_native.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/mobile/test/infrastructure/repositories/local_album_repository_test.dart b/mobile/test/infrastructure/repositories/local_album_repository_test.dart index ada4281e2a..7498360c87 100644 --- a/mobile/test/infrastructure/repositories/local_album_repository_test.dart +++ b/mobile/test/infrastructure/repositories/local_album_repository_test.dart @@ -23,7 +23,7 @@ void main() { group('getAll', () { test('sorts albums by backupSelection & isIosSharedAlbum', () async { - final localAlbumRepo = mediumFactory.getRepository(); + final localAlbumRepo = mediumFactory.getRepository(); await localAlbumRepo.upsert(mediumFactory.localAlbum(id: '1', backupSelection: BackupSelection.none)); await localAlbumRepo.upsert(mediumFactory.localAlbum(id: '2', backupSelection: BackupSelection.excluded)); await localAlbumRepo.upsert( @@ -46,7 +46,7 @@ void main() { // Android was dropped. The delta reports only the asset's new album, and the // stale link to its old album made the per-album delete sweep wipe the asset. test('keeps an asset moved to another album that still holds other assets', () async { - final localAlbumRepo = mediumFactory.getRepository(); + final localAlbumRepo = mediumFactory.getRepository(); final moved = _localAsset('moved'); final other = _localAsset('other'); @@ -83,7 +83,7 @@ void main() { }); test('replaces album membership with exactly what the delta reports', () async { - final localAlbumRepo = mediumFactory.getRepository(); + final localAlbumRepo = mediumFactory.getRepository(); final moved = _localAsset('moved'); await localAlbumRepo.upsert(mediumFactory.localAlbum(id: 'src'), toUpsert: [moved]); diff --git a/mobile/test/infrastructure/repositories/store_repository_test.dart b/mobile/test/infrastructure/repositories/store_repository_test.dart index 3e160c29ca..08ed5f66ab 100644 --- a/mobile/test/infrastructure/repositories/store_repository_test.dart +++ b/mobile/test/infrastructure/repositories/store_repository_test.dart @@ -47,11 +47,11 @@ Future _populateStore(Drift db) async { void main() { late Drift db; - late DriftStoreRepository sut; + late StoreRepository sut; setUp(() async { db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - sut = DriftStoreRepository(db); + sut = StoreRepository(db); }); tearDown(() async { @@ -100,7 +100,7 @@ void main() { test('delete()', () async { bool? advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting); expect(advancedTroubleshooting, isFalse); - await sut.delete(StoreKey.advancedTroubleshooting); + await sut.deleteValue(StoreKey.advancedTroubleshooting); advancedTroubleshooting = await sut.tryGet(StoreKey.advancedTroubleshooting); expect(advancedTroubleshooting, isNull); }); diff --git a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart index 3a3e40cbae..5253838fef 100644 --- a/mobile/test/infrastructure/repositories/sync_api_repository_test.dart +++ b/mobile/test/infrastructure/repositories/sync_api_repository_test.dart @@ -41,7 +41,7 @@ void main() { setUpAll(() async { final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); setUp(() { @@ -189,7 +189,7 @@ void main() { final Completer firstBatchReceived = Completer(); final Completer secondBatchReceived = Completer(); - Future onDataCallback(List events, Function() _, Function() __) async { + Future onDataCallback(List events, Function() _, Function() _) async { onDataCallCount++; if (onDataCallCount == 1) { receivedEventsBatch1 = events; @@ -244,7 +244,7 @@ void main() { final streamError = Exception("Network Error"); int onDataCallCount = 0; - Future onDataCallback(List _, Function() _, Function() __) async { + Future onDataCallback(List _, Function() _, Function() _) async { onDataCallCount++; } @@ -270,7 +270,7 @@ void main() { when(() => mockStreamedResponse.stream).thenAnswer((_) => http.ByteStream(errorBodyController.stream)); int onDataCallCount = 0; - Future onDataCallback(List _, Function() _, Function() __) async { + Future onDataCallback(List _, Function() _, Function() _) async { onDataCallCount++; } diff --git a/mobile/test/infrastructure/repository.mock.dart b/mobile/test/infrastructure/repository.mock.dart index 355e128fc0..ef266b243c 100644 --- a/mobile/test/infrastructure/repository.mock.dart +++ b/mobile/test/infrastructure/repository.mock.dart @@ -1,7 +1,9 @@ import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/log.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; @@ -18,7 +20,11 @@ import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:mocktail/mocktail.dart'; -class MockDriftStoreRepository extends Mock implements DriftStoreRepository {} +class MockDrift extends Mock implements Drift {} + +class MockMemoryRepository extends Mock implements MemoryRepository {} + +class MockStoreRepository extends Mock implements StoreRepository {} class MockSettingsRepository extends Mock implements SettingsRepository {} @@ -26,21 +32,19 @@ class MockLogRepository extends Mock implements LogRepository {} class MockSyncStreamRepository extends Mock implements SyncStreamRepository {} -class MockLocalAlbumRepository extends Mock implements DriftLocalAlbumRepository {} +class MockLocalAlbumRepository extends Mock implements LocalAlbumRepository {} -class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumRepository {} +class MockRemoteAlbumRepository extends Mock implements RemoteAlbumRepository {} -class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} - -class MockDriftLocalAssetRepository extends Mock implements DriftLocalAssetRepository {} +class MockLocalAssetRepository extends Mock implements LocalAssetRepository {} class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {} -class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {} +class MockTrashedLocalAssetRepository extends Mock implements TrashedLocalAssetRepository {} class MockStorageRepository extends Mock implements StorageRepository {} -class MockDriftBackupRepository extends Mock implements DriftBackupRepository {} +class MockBackupRepository extends Mock implements BackupRepository {} class MockUploadRepository extends Mock implements UploadRepository {} diff --git a/mobile/test/medium/repositories/backup_repository_test.dart b/mobile/test/medium/repositories/backup_repository_test.dart index 00ab28df87..8e56502022 100644 --- a/mobile/test/medium/repositories/backup_repository_test.dart +++ b/mobile/test/medium/repositories/backup_repository_test.dart @@ -7,11 +7,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftBackupRepository sut; + late BackupRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftBackupRepository(ctx.db); + sut = BackupRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/local_asset_repository_test.dart b/mobile/test/medium/repositories/local_asset_repository_test.dart index 2376445d1a..bd048a13fd 100644 --- a/mobile/test/medium/repositories/local_asset_repository_test.dart +++ b/mobile/test/medium/repositories/local_asset_repository_test.dart @@ -8,11 +8,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftLocalAssetRepository sut; + late LocalAssetRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftLocalAssetRepository(ctx.db); + sut = LocalAssetRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/memory_repository_test.dart b/mobile/test/medium/repositories/memory_repository_test.dart index e57098f3bd..af9309f4aa 100644 --- a/mobile/test/medium/repositories/memory_repository_test.dart +++ b/mobile/test/medium/repositories/memory_repository_test.dart @@ -5,11 +5,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftMemoryRepository sut; + late MemoryRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftMemoryRepository(ctx.db); + sut = MemoryRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/partner_repository_test.dart b/mobile/test/medium/repositories/partner_repository_test.dart index 298b8b852d..4d730a0be4 100644 --- a/mobile/test/medium/repositories/partner_repository_test.dart +++ b/mobile/test/medium/repositories/partner_repository_test.dart @@ -78,7 +78,7 @@ void main() { final sharer = await ctx.newUser(); await ctx.newPartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: false); - await sut.update(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true); + await sut.updatePartner(sharedById: sharer.id, sharedWithId: me.id, inTimeline: true); final result = await sut.get(sharedById: sharer.id, sharedWithId: me.id); expect(result.inTimeline, isTrue); @@ -91,7 +91,7 @@ void main() { final recipient = await ctx.newUser(); await ctx.newPartner(sharedById: me.id, sharedWithId: recipient.id); - await sut.delete(sharedById: me.id, sharedWithId: recipient.id); + await sut.deletePartner(sharedById: me.id, sharedWithId: recipient.id); final rows = await ctx.db.select(ctx.db.partnerEntity).get(); expect(rows, isEmpty); diff --git a/mobile/test/medium/repositories/people_repository_test.dart b/mobile/test/medium/repositories/people_repository_test.dart index 4b51862a7d..979dd95373 100644 --- a/mobile/test/medium/repositories/people_repository_test.dart +++ b/mobile/test/medium/repositories/people_repository_test.dart @@ -5,11 +5,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftPeopleRepository sut; + late PeopleRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftPeopleRepository(ctx.db); + sut = PeopleRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/remote_album_repository_test.dart b/mobile/test/medium/repositories/remote_album_repository_test.dart index 5a82ff315f..b05a687f16 100644 --- a/mobile/test/medium/repositories/remote_album_repository_test.dart +++ b/mobile/test/medium/repositories/remote_album_repository_test.dart @@ -6,11 +6,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftRemoteAlbumRepository sut; + late RemoteAlbumRepository sut; setUp(() async { ctx = MediumRepositoryContext(); - sut = DriftRemoteAlbumRepository(ctx.db); + sut = RemoteAlbumRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repositories/timeline_repository_test.dart b/mobile/test/medium/repositories/timeline_repository_test.dart index 502a65d45b..3de31e9665 100644 --- a/mobile/test/medium/repositories/timeline_repository_test.dart +++ b/mobile/test/medium/repositories/timeline_repository_test.dart @@ -7,7 +7,7 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftTimelineRepository sut; + late TimelineRepository sut; setUpAll(() async { await initializeDateFormatting(); @@ -15,7 +15,7 @@ void main() { setUp(() { ctx = MediumRepositoryContext(); - sut = DriftTimelineRepository(ctx.db); + sut = TimelineRepository(ctx.db); }); tearDown(() async { @@ -46,6 +46,60 @@ void main() { expect((assets.first as RemoteAsset).id, remoteAsset.id); expect([localAsset1.id, localAsset2.id], contains((assets.first as RemoteAsset).localId)); }); + + test('orders shifted album assets in both directions and keeps normal asset order (#28852)', () async { + final user = await ctx.newUser(); + final descendingAlbum = await ctx.newRemoteAlbum(ownerId: user.id, order: .desc); + final ascendingAlbum = await ctx.newRemoteAlbum(ownerId: user.id, order: .asc); + final shiftedLater = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 2, 12), + localDateTime: DateTime.utc(2024, 9, 3, 12), + ); + final shiftedEarlier = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 3, 12), + localDateTime: DateTime.utc(2024, 9, 2, 12), + ); + final normalLater = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 4, 14), + localDateTime: DateTime.utc(2024, 9, 4, 14), + ); + final normalEarlier = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 4, 12), + localDateTime: DateTime.utc(2024, 9, 4, 12), + ); + final seeded = [shiftedLater, shiftedEarlier, normalLater, normalEarlier]; + for (final asset in seeded) { + await ctx.newRemoteAlbumAsset(albumId: descendingAlbum.id, assetId: asset.id); + await ctx.newRemoteAlbumAsset(albumId: ascendingAlbum.id, assetId: asset.id); + } + + final descending = sut.remoteAlbum(descendingAlbum.id, .day); + final ascending = sut.remoteAlbum(ascendingAlbum.id, .day); + + final buckets = await descending.bucketSource().first; + expect(buckets, hasLength(3)); + expect(buckets.map((bucket) => bucket.assetCount), [2, 1, 1]); + + final descendingAssets = await descending.assetSource(0, 10); + expect(descendingAssets.map((asset) => (asset as RemoteAsset).id), [ + normalLater.id, + normalEarlier.id, + shiftedLater.id, + shiftedEarlier.id, + ]); + + final ascendingAssets = await ascending.assetSource(0, 10); + expect(ascendingAssets.map((asset) => (asset as RemoteAsset).id), [ + shiftedEarlier.id, + shiftedLater.id, + normalEarlier.id, + normalLater.id, + ]); + }); }); group('person assets', () { @@ -69,6 +123,31 @@ void main() { expect(assets, hasLength(1)); expect((assets.first as RemoteAsset).id, asset.id); }); + + test('orders shifted person assets by effective date (#28852)', () async { + final user = await ctx.newUser(); + final person = await ctx.newPerson(ownerId: user.id); + final shiftedLater = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 2, 12), + localDateTime: DateTime.utc(2024, 9, 3, 12), + ); + final shiftedEarlier = await ctx.newRemoteAsset( + ownerId: user.id, + createdAt: DateTime.utc(2024, 9, 3, 12), + localDateTime: DateTime.utc(2024, 9, 2, 12), + ); + await ctx.newFace(assetId: shiftedLater.id, personId: person.id); + await ctx.newFace(assetId: shiftedEarlier.id, personId: person.id); + + final query = sut.person(user.id, person.id, .day); + + final buckets = await query.bucketSource().first; + expect(buckets, hasLength(2)); + + final assets = await query.assetSource(0, 10); + expect(assets.map((asset) => (asset as RemoteAsset).id), [shiftedLater.id, shiftedEarlier.id]); + }); }); group('live photos', () { diff --git a/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart b/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart index 0af09715c7..532502ca3d 100644 --- a/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart +++ b/mobile/test/medium/repositories/trashed_local_asset_repository_test.dart @@ -6,11 +6,11 @@ import '../repository_context.dart'; void main() { late MediumRepositoryContext ctx; - late DriftTrashedLocalAssetRepository sut; + late TrashedLocalAssetRepository sut; setUp(() { ctx = MediumRepositoryContext(); - sut = DriftTrashedLocalAssetRepository(ctx.db); + sut = TrashedLocalAssetRepository(ctx.db); }); tearDown(() async { diff --git a/mobile/test/medium/repository_context.dart b/mobile/test/medium/repository_context.dart index 09b8e2c7eb..c2d7a1f380 100644 --- a/mobile/test/medium/repository_context.dart +++ b/mobile/test/medium/repository_context.dart @@ -121,6 +121,7 @@ class MediumRepositoryContext { String? stackId, String? thumbHash, String? libraryId, + DateTime? localDateTime, }) async { id ??= TestUtils.uuid(); createdAt ??= TestUtils.date(); @@ -144,7 +145,7 @@ class MediumRepositoryContext { isEdited: .new(isEdited ?? false), livePhotoVideoId: .new(livePhotoVideoId), stackId: .new(stackId), - localDateTime: .new(createdAt.toLocal()), + localDateTime: .new(localDateTime ?? createdAt.toLocal()), thumbHash: .new(TestUtils.uuid(thumbHash)), libraryId: .new(TestUtils.uuid(libraryId)), ), diff --git a/mobile/test/modules/map/map_theme_override_test.dart b/mobile/test/modules/map/map_theme_override_test.dart index 817ce0c4b1..3ea54bc9b5 100644 --- a/mobile/test/modules/map/map_theme_override_test.dart +++ b/mobile/test/modules/map/map_theme_override_test.dart @@ -32,7 +32,7 @@ void main() { setUp(() async { mapState = const MapState(themeMode: ThemeMode.dark); mapStateNotifier = MockMapStateNotifier(mapState); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); overrides = [ mapStateNotifierProvider.overrideWith(() => mapStateNotifier), localeProvider.overrideWithValue(const Locale("en")), diff --git a/mobile/test/pages/common/download_panel_test.dart b/mobile/test/pages/common/download_panel_test.dart new file mode 100644 index 0000000000..f2a6b09d88 --- /dev/null +++ b/mobile/test/pages/common/download_panel_test.dart @@ -0,0 +1,24 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/pages/common/download_panel.dart'; + +import '../../widget_tester_extensions.dart'; + +void main() { + testWidgets('complete uses a checkmark and other statuses use close', (tester) async { + await tester.pumpConsumerWidget( + DownloadTaskTile(progress: 1, fileName: 'photo.jpg', status: TaskStatus.complete, onCancelDownload: () {}), + ); + + expect(find.byIcon(Icons.download_done), findsOneWidget); + expect(find.byIcon(Icons.close), findsNothing); + + await tester.pumpConsumerWidget( + DownloadTaskTile(progress: 1, fileName: 'photo.jpg', status: TaskStatus.failed, onCancelDownload: () {}), + ); + + expect(find.byIcon(Icons.download_done), findsNothing); + expect(find.byIcon(Icons.close), findsOneWidget); + }); +} diff --git a/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart b/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart index d52f6b6b83..f4ee4f353c 100644 --- a/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart +++ b/mobile/test/presentation/widgets/asset_viewer/asset_page_zoom_reset_test.dart @@ -20,7 +20,7 @@ class _SwappableTimelineService extends TimelineService { BaseAsset asset; _SwappableTimelineService(this.asset) : super(( - assetSource: (_, __) async => [], + assetSource: (_, _) async => [], bucketSource: () => Stream.value(const [Bucket(assetCount: 1)]), origin: TimelineOrigin.main, )); diff --git a/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart b/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart index 02bb0c1053..3a5ad159b5 100644 --- a/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart +++ b/mobile/test/presentation/widgets/images/cache_aware_listener_tracker_mixin_test.dart @@ -29,7 +29,7 @@ void main() { setUp(() { // Create a fresh, real Flutter ImageCache for every test cache = ImageCache(); - uiListener = ImageStreamListener((_, __) {}); + uiListener = ImageStreamListener((_, _) {}); }); group('CacheAwareListenerTrackerMixin with Real ImageCache', () { @@ -130,7 +130,7 @@ void main() { final stream = cache.putIfAbsent(key, () => completer)!; - final uiListener2 = ImageStreamListener((_, __) {}); + final uiListener2 = ImageStreamListener((_, _) {}); stream.addListener(uiListener); stream.addListener(uiListener2); @@ -157,7 +157,7 @@ void main() { expect(completer.wasCancelled, isFalse); // A second UI listener attaches — must NOT be treated as cache - final uiListener2 = ImageStreamListener((_, __) {}); + final uiListener2 = ImageStreamListener((_, _) {}); stream.addListener(uiListener2); // Remove first UI listener; second UI still active → no cancel diff --git a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart index 5c03998bfc..439ae9ecf1 100644 --- a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart +++ b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart @@ -64,7 +64,7 @@ void main() { TimelineArgs? probed; final probe = Consumer( - builder: (_, ref, __) { + builder: (_, ref, _) { probed = ref.watch(timelineArgsProvider); return const SizedBox.shrink(); }, @@ -103,7 +103,7 @@ void main() { TimelineArgs? probed; final probe = SliverToBoxAdapter( child: Consumer( - builder: (_, ref, __) { + builder: (_, ref, _) { probed = ref.watch(timelineArgsProvider); return const SizedBox.shrink(); }, @@ -148,7 +148,7 @@ void main() { TimelineArgs? probed; final probe = Consumer( - builder: (_, ref, __) { + builder: (_, ref, _) { probed = ref.watch(timelineArgsProvider); return const SizedBox.shrink(); }, diff --git a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart index 28644d6702..e85ced8fb1 100644 --- a/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart +++ b/mobile/test/presentation/widgets/timeline/timeline_scroll_to_top_test.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import '../../../fixtures/asset.stub.dart'; @@ -50,7 +51,7 @@ void main() { page: PageInfo('Overlay', builder: (_) => const Scaffold(body: SizedBox.shrink())), type: RouteType.custom( customRouteBuilder: (_, child, page) => - PageRouteBuilder(settings: page, opaque: false, pageBuilder: (_, __, ___) => child), + PageRouteBuilder(settings: page, opaque: false, pageBuilder: (_, _, _) => child), ), ), ], @@ -62,7 +63,9 @@ void main() { timelineServiceProvider.overrideWithValue(service), appConfigProvider.overrideWithValue(const AppConfig()), ], - child: MaterialApp.router(routerConfig: router.config()), + child: MaterialApp.router( + routerConfig: router.config(navigatorObservers: () => [TransitioningRouteObserver()]), + ), ), ); // Segment stream resolves @@ -96,9 +99,14 @@ void main() { await tester.pumpAndSettle(); expect(position.pixels, initialScrolledPosition, reason: 'ignored while behind a pushed route'); - // Once the timeline is visible, taps on the status bar should scroll to the top - await router.maybePop(); + // The tap is async, so it can arrive while a pushed route is transitioning to popped + unawaited(router.maybePop()); + await tester.pump(); + await tapStatusBar(); await tester.pumpAndSettle(); + expect(position.pixels, initialScrolledPosition, reason: 'ignored while the pushed route pops'); + + // Once the timeline is visible, taps on the status bar should scroll to the top await tapStatusBar(); await tester.pumpAndSettle(); expect(position.pixels, 0, reason: 'scrolls the foreground timeline'); diff --git a/mobile/test/providers/asset_viewer/download_provider_test.dart b/mobile/test/providers/asset_viewer/download_provider_test.dart new file mode 100644 index 0000000000..2e2694475d --- /dev/null +++ b/mobile/test/providers/asset_viewer/download_provider_test.dart @@ -0,0 +1,113 @@ +import 'package:background_downloader/background_downloader.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockDownloadService extends Mock implements DownloadService {} + +DownloadTask _task(String id, {String filename = 'photo.jpg', String metaData = ''}) => + DownloadTask(taskId: id, url: 'https://example.com/$filename', filename: filename, metaData: metaData); + +void main() { + late MockDownloadService service; + late DownloadStateNotifier notifier; + late void Function(TaskProgressUpdate) onProgress; + late void Function(TaskStatusUpdate) onImage; + late void Function(TaskStatusUpdate) onLivePhoto; + + setUp(() { + service = MockDownloadService(); + notifier = DownloadStateNotifier(service); + addTearDown(() { + if (notifier.mounted) { + notifier.dispose(); + } + }); + + onProgress = verify(() => service.onTaskProgress = captureAny()).captured.last as void Function(TaskProgressUpdate); + onImage = + verify(() => service.onImageDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate); + onLivePhoto = + verify(() => service.onLivePhotoDownloadStatus = captureAny()).captured.last as void Function(TaskStatusUpdate); + }); + + test('complete flips the entry and keeps it visible', () { + fakeAsync((async) { + final task = _task('task-1'); + onProgress(TaskProgressUpdate(task, 0.4)); + onImage(TaskStatusUpdate(task, TaskStatus.complete)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.complete); + expect(notifier.state.showProgress, isTrue); + + async.elapse(const Duration(seconds: 5)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.complete); + expect(notifier.state.showProgress, isTrue); + }); + }); + + test('failed keeps the entry visible', () { + fakeAsync((async) { + final task = _task('task-1'); + onProgress(TaskProgressUpdate(task, 0.4)); + onImage(TaskStatusUpdate(task, TaskStatus.failed)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.failed); + + async.elapse(const Duration(seconds: 5)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.failed); + expect(notifier.state.showProgress, isTrue); + }); + }); + + test('a live photo part completion keeps both entries', () { + fakeAsync((async) { + final image = _task( + 'live-image', + metaData: LivePhotosMetadata(part: LivePhotosPart.image, id: 'live-1').toJson(), + ); + final video = _task( + 'live-video', + filename: 'photo.MOV', + metaData: LivePhotosMetadata(part: LivePhotosPart.video, id: 'live-1').toJson(), + ); + onProgress(TaskProgressUpdate(image, 0.9)); + onProgress(TaskProgressUpdate(video, 0.9)); + onLivePhoto(TaskStatusUpdate(image, TaskStatus.complete)); + + async.elapse(const Duration(seconds: 5)); + + expect(notifier.state.taskProgress['live-image']?.status, TaskStatus.complete); + expect(notifier.state.taskProgress['live-video']?.status, TaskStatus.running); + expect(notifier.state.showProgress, isTrue); + }); + }); + + test('canceled does not resurrect or alter the entry', () { + fakeAsync((async) { + final task = _task('task-1'); + onProgress(TaskProgressUpdate(task, 0.4)); + onImage(TaskStatusUpdate(task, TaskStatus.canceled)); + + expect(notifier.state.taskProgress['task-1']?.status, TaskStatus.running); + expect(notifier.state.taskProgress['task-1']?.progress, 0.4); + + onImage(TaskStatusUpdate(_task('ghost'), TaskStatus.canceled)); + expect(notifier.state.taskProgress.containsKey('ghost'), isFalse); + }); + }); + + test('a status for an unknown task does not create an entry', () { + fakeAsync((async) { + onImage(TaskStatusUpdate(_task('ghost'), TaskStatus.complete)); + + expect(notifier.state.taskProgress, isEmpty); + expect(notifier.state.showProgress, isFalse); + }); + }); +} diff --git a/mobile/test/providers/infrastructure/memory_provider_test.dart b/mobile/test/providers/infrastructure/memory_provider_test.dart index 2bd5ed4267..2e77d829af 100644 --- a/mobile/test/providers/infrastructure/memory_provider_test.dart +++ b/mobile/test/providers/infrastructure/memory_provider_test.dart @@ -2,18 +2,20 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; -import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/memory.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:mocktail/mocktail.dart'; -class MockDriftMemoryService extends Mock implements DriftMemoryService {} +import '../../infrastructure/repository.mock.dart'; class MockUserService extends Mock implements UserService {} void main() { - late MockDriftMemoryService memoryService; + late MockMemoryRepository memoryRepository; late MockUserService userService; UserDto user({bool memoryEnabled = true}) => UserDto( @@ -24,10 +26,16 @@ void main() { profileChangedAt: DateTime(2026), ); + Drift mockDrift(MemoryRepository repository) { + final drift = MockDrift(); + when(() => drift.memoryRepository).thenReturn(repository); + return drift; + } + ProviderContainer makeContainer() { final container = ProviderContainer( overrides: [ - driftMemoryServiceProvider.overrideWithValue(memoryService), + driftProvider.overrideWithValue(mockDrift(memoryRepository)), currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)), ], ); @@ -36,10 +44,10 @@ void main() { } setUp(() { - memoryService = MockDriftMemoryService(); + memoryRepository = MockMemoryRepository(); userService = MockUserService(); - when(() => memoryService.getMemoryLane('user-1')).thenAnswer((_) async => []); + when(() => memoryRepository.getAll('user-1')).thenAnswer((_) async => []); when(() => userService.tryGetMyUser()).thenReturn(user()); when(() => userService.watchMyUser()).thenAnswer((_) => const Stream.empty()); }); @@ -48,33 +56,33 @@ void main() { test('re-queries after local midnight', () { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryLaneProvider, (_, __) {}); + container.listen(driftMemoryLaneProvider, (_, _) {}); async.flushMicrotasks(); - verify(() => memoryService.getMemoryLane('user-1')).called(1); + verify(() => memoryRepository.getAll('user-1')).called(1); async.elapse(const Duration(seconds: 4)); async.flushMicrotasks(); - verifyNever(() => memoryService.getMemoryLane('user-1')); + verifyNever(() => memoryRepository.getAll('user-1')); async.elapse(const Duration(hours: 25)); async.flushMicrotasks(); - verify(() => memoryService.getMemoryLane('user-1')).called(greaterThanOrEqualTo(1)); + verify(() => memoryRepository.getAll('user-1')).called(greaterThanOrEqualTo(1)); }); }); test('cancels the midnight timer when disposed', () { fakeAsync((async) { final container = makeContainer(); - final subscription = container.listen(driftMemoryLaneProvider, (_, __) {}); + final subscription = container.listen(driftMemoryLaneProvider, (_, _) {}); async.flushMicrotasks(); - verify(() => memoryService.getMemoryLane('user-1')).called(1); + verify(() => memoryRepository.getAll('user-1')).called(1); subscription.close(); async.elapse(const Duration(hours: 25)); async.flushMicrotasks(); - verifyNever(() => memoryService.getMemoryLane('user-1')); + verifyNever(() => memoryRepository.getAll('user-1')); }); }); @@ -83,13 +91,13 @@ void main() { fakeAsync((async) { final container = makeContainer(); - container.listen(driftMemoryLaneProvider, (_, __) {}); + container.listen(driftMemoryLaneProvider, (_, _) {}); async.flushMicrotasks(); async.elapse(const Duration(hours: 25)); async.flushMicrotasks(); - verifyNever(() => memoryService.getMemoryLane(any())); + verifyNever(() => memoryRepository.getAll(any())); }); }); }); diff --git a/mobile/test/repositories/upload_repository_test.dart b/mobile/test/repositories/upload_repository_test.dart new file mode 100644 index 0000000000..516559de50 --- /dev/null +++ b/mobile/test/repositories/upload_repository_test.dart @@ -0,0 +1,127 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; +import 'package:immich_mobile/repositories/upload.repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockHttpClient extends Mock implements http.Client {} + +class _FakeBaseRequest extends Fake implements http.BaseRequest {} + +// keeps the FileDownloader singleton off the disk and off the platform channels +class _NoStorage extends Fake implements PersistentStorage { + @override + Future initialize() async {} +} + +void main() { + late _MockHttpClient client; + late UploadRepository sut; + late File file; + + setUpAll(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + FileDownloader(persistentStorage: _NoStorage()); + final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); + await StoreService.init(storeRepository: StoreRepository(db)); + await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app/api'); + registerFallbackValue(_FakeBaseRequest()); + file = File('${Directory.systemTemp.createTempSync().path}/photo.jpg')..writeAsStringSync('bytes'); + }); + + setUp(() { + client = _MockHttpClient(); + sut = UploadRepository(); + }); + + // consumes the body like a real client would, so a reused request would blow up on the second send + void stubSend(FutureOr Function(int attempt) answer) { + var attempt = 0; + when(() => client.send(any())).thenAnswer((invocation) async { + final request = invocation.positionalArguments.single as http.BaseRequest; + await request.finalize().drain(); + return answer(++attempt); + }); + } + + http.StreamedResponse response(int status, String body) => + http.StreamedResponse(Stream.value(utf8.encode(body)), status); + + Future upload() => sut.uploadFile( + file: file, + originalFileName: 'photo.jpg', + fields: const {'deviceAssetId': 'a1'}, + cancelToken: null, + logContext: 'a1', + httpClient: client, + ); + + test('resends once when the first send dies before a response', () async { + stubSend((attempt) { + if (attempt == 1) { + throw http.ClientException('Broken pipe'); + } + return response(201, '{"id":"remote-1"}'); + }); + + final result = await upload(); + + expect(result.isSuccess, isTrue); + expect(result.remoteAssetId, 'remote-1'); + verify(() => client.send(any())).called(2); + }); + + test('a second transport failure is an error, no third send', () async { + stubSend((_) => throw http.ClientException('Connection reset')); + + final result = await upload(); + + expect(result.isSuccess, isFalse); + expect(result.isCancelled, isFalse); + verify(() => client.send(any())).called(2); + }); + + test('a cancelled upload is not resent', () async { + stubSend((_) => throw http.RequestAbortedException()); + + final result = await upload(); + + expect(result.isCancelled, isTrue); + verify(() => client.send(any())).called(1); + }); + + test('a cancel during the resend still counts as cancelled', () async { + stubSend((attempt) { + if (attempt == 1) { + throw http.ClientException('Broken pipe'); + } + throw http.RequestAbortedException(); + }); + + final result = await upload(); + + expect(result.isCancelled, isTrue); + verify(() => client.send(any())).called(2); + }); + + test('a server error response is not resent', () async { + stubSend((_) => response(500, '{"message":"boom"}')); + + final result = await upload(); + + expect(result.statusCode, 500); + expect(result.errorMessage, 'boom'); + verify(() => client.send(any())).called(1); + }); +} diff --git a/mobile/test/services/action.service_test.dart b/mobile/test/services/action.service_test.dart index 2d90e24503..b008ff9bfc 100644 --- a/mobile/test/services/action.service_test.dart +++ b/mobile/test/services/action.service_test.dart @@ -25,7 +25,7 @@ void main() { debugDefaultTargetPlatformOverride = TargetPlatform.android; db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/services/auth.service_test.dart b/mobile/test/services/auth.service_test.dart index b62ad3ad2c..6d79993aa8 100644 --- a/mobile/test/services/auth.service_test.dart +++ b/mobile/test/services/auth.service_test.dart @@ -36,7 +36,7 @@ void main() { setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); }); tearDownAll(() async { diff --git a/mobile/test/services/background_upload.service_test.dart b/mobile/test/services/background_upload.service_test.dart index aa46bd9de3..2d9b5b1209 100644 --- a/mobile/test/services/background_upload.service_test.dart +++ b/mobile/test/services/background_upload.service_test.dart @@ -25,8 +25,8 @@ void main() { late BackgroundUploadService sut; late MockUploadRepository mockUploadRepository; late MockStorageRepository mockStorageRepository; - late MockDriftLocalAssetRepository mockLocalAssetRepository; - late MockDriftBackupRepository mockBackupRepository; + late MockLocalAssetRepository mockLocalAssetRepository; + late MockBackupRepository mockBackupRepository; late MockAssetMediaRepository mockAssetMediaRepository; late Drift db; @@ -37,7 +37,7 @@ void main() { (MethodCall methodCall) async => 'test', ); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); await SettingsRepository.ensureInitialized(db); await Store.put(StoreKey.serverEndpoint, 'http://test-server.com'); @@ -47,8 +47,8 @@ void main() { setUp(() { mockUploadRepository = MockUploadRepository(); mockStorageRepository = MockStorageRepository(); - mockLocalAssetRepository = MockDriftLocalAssetRepository(); - mockBackupRepository = MockDriftBackupRepository(); + mockLocalAssetRepository = MockLocalAssetRepository(); + mockBackupRepository = MockBackupRepository(); mockAssetMediaRepository = MockAssetMediaRepository(); sut = BackgroundUploadService( diff --git a/mobile/test/services/cleanup.service_test.dart b/mobile/test/services/cleanup.service_test.dart index 2038941ecb..294b8550ea 100644 --- a/mobile/test/services/cleanup.service_test.dart +++ b/mobile/test/services/cleanup.service_test.dart @@ -9,11 +9,11 @@ import '../repository.mocks.dart'; void main() { late CleanupService sut; - late MockDriftLocalAssetRepository localAssetRepository; + late MockLocalAssetRepository localAssetRepository; late MockAssetMediaRepository assetMediaRepository; setUp(() { - localAssetRepository = MockDriftLocalAssetRepository(); + localAssetRepository = MockLocalAssetRepository(); assetMediaRepository = MockAssetMediaRepository(); sut = CleanupService(localAssetRepository, assetMediaRepository); }); @@ -24,7 +24,7 @@ void main() { expect(result, 0); verifyNever(() => assetMediaRepository.deleteAll(any())); - verifyNever(() => localAssetRepository.delete(any())); + verifyNever(() => localAssetRepository.deleteAssets(any())); }); test('deletes in a single batch when under limit', () async { @@ -33,13 +33,13 @@ void main() { when(() => assetMediaRepository.deleteAll(any())).thenAnswer((invocation) async { return (invocation.positionalArguments.first as List).toList(); }); - when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + when(() => localAssetRepository.deleteAssets(any())).thenAnswer((_) async {}); final result = await sut.deleteLocalAssets(ids); expect(result, ids.length); verify(() => assetMediaRepository.deleteAll(ids)).called(1); - verify(() => localAssetRepository.delete(ids)).called(1); + verify(() => localAssetRepository.deleteAssets(ids)).called(1); }); test('deletes in platform-specific batches when over limit', () async { @@ -52,7 +52,7 @@ void main() { capturedBatches.add(batch); return batch; }); - when(() => localAssetRepository.delete(any())).thenAnswer((_) async {}); + when(() => localAssetRepository.deleteAssets(any())).thenAnswer((_) async {}); final result = await sut.deleteLocalAssets(ids); @@ -67,7 +67,7 @@ void main() { expect(capturedBatches[1].last, 'asset-${batchSize * 2 - 1}'); expect(capturedBatches[2].first, 'asset-${batchSize * 2}'); expect(capturedBatches[2].last, 'asset-${batchSize * 2 + 500}'); - verify(() => localAssetRepository.delete(any())).called(3); + verify(() => localAssetRepository.deleteAssets(any())).called(3); }); }); } diff --git a/mobile/test/services/deep_link_service_test.dart b/mobile/test/services/deep_link_service_test.dart index 16fa392f27..c588cf26b4 100644 --- a/mobile/test/services/deep_link_service_test.dart +++ b/mobile/test/services/deep_link_service_test.dart @@ -3,7 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/memory.service.dart'; import 'package:immich_mobile/domain/services/people.service.dart'; @@ -32,11 +34,13 @@ class MockAssetViewerStateNotifier extends Mock implements AssetViewerStateNotif const _assetId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; const _albumId = 'cccccccc-4444-5555-6666-dddddddddddd'; +const _memoryId = 'mmmmmmmm-7777-8888-9999-yyyyyyyyyyyy'; +const _userId = 'user-1'; final _asset = RemoteAsset( id: _assetId, name: 'photo.jpg', - ownerId: 'user-1', + ownerId: _userId, checksum: 'checksum-1', type: AssetType.image, createdAt: DateTime(2026, 6, 12), @@ -47,7 +51,7 @@ final _asset = RemoteAsset( final _album = RemoteAlbum( id: _albumId, name: 'Shared Album', - ownerId: 'user-1', + ownerId: _userId, description: '', createdAt: DateTime(2026, 6, 12), updatedAt: DateTime(2026, 6, 12), @@ -58,10 +62,30 @@ final _album = RemoteAlbum( ownerName: 'Owner', ); +final _memory = DriftMemory( + id: _memoryId, + createdAt: DateTime(2026, 6, 12), + updatedAt: DateTime(2026, 6, 12), + ownerId: _userId, + type: MemoryTypeEnum.onThisDay, + data: const MemoryData(year: 2025), + isSaved: false, + memoryAt: DateTime(2025, 6, 12), + assets: [_asset], +); + +final _user = UserDto( + id: _userId, + email: 'test@immich.app', + name: 'Test User', + profileChangedAt: DateTime(2026, 6, 12), +); + void main() { late MockTimelineFactory timelineFactory; late MockAssetService assetService; late MockRemoteAlbumService remoteAlbumService; + late MockDriftMemoryService memoryService; late MockWidgetRef ref; late List createdTimelineServices; late DeepLinkService sut; @@ -70,6 +94,7 @@ void main() { timelineFactory = MockTimelineFactory(); assetService = MockAssetService(); remoteAlbumService = MockRemoteAlbumService(); + memoryService = MockDriftMemoryService(); ref = MockWidgetRef(); createdTimelineServices = []; @@ -90,9 +115,9 @@ void main() { timelineFactory, assetService, remoteAlbumService, - MockDriftMemoryService(), + memoryService, MockDriftPeopleService(), - null, + _user, ); addTearDown(() async { @@ -137,4 +162,30 @@ void main() { expect((route!.args! as AssetViewerRouteArgs).currentAlbum, isNull); verifyNever(() => remoteAlbumService.get(any())); }); + + test('memory scheme link without an id opens the memory lane', () async { + when(() => memoryService.getMemoryLane(_userId)).thenAnswer((_) async => [_memory]); + final deepLink = MockPlatformDeepLink(); + when(() => deepLink.uri).thenReturn(Uri.parse('immich://memory')); + + final route = await sut.handleScheme(deepLink, ref); + + expect(route, isA()); + expect((route!.args! as DriftMemoryRouteArgs).memories, [_memory]); + verify(() => memoryService.getMemoryLane(_userId)).called(1); + verifyNever(() => memoryService.get(any())); + }); + + test('memory scheme link with an id opens only the specified memory', () async { + when(() => memoryService.get(_memoryId)).thenAnswer((_) async => _memory); + final deepLink = MockPlatformDeepLink(); + when(() => deepLink.uri).thenReturn(Uri.parse('immich://memory?id=$_memoryId')); + + final route = await sut.handleScheme(deepLink, ref); + + expect(route, isA()); + expect((route!.args! as DriftMemoryRouteArgs).memories, [_memory]); + verifyNever(() => memoryService.getMemoryLane(any())); + verify(() => memoryService.get(_memoryId)).called(1); + }); } diff --git a/mobile/test/services/foreground_upload.service_test.dart b/mobile/test/services/foreground_upload.service_test.dart index d4344ed77e..4f0a01dfd0 100644 --- a/mobile/test/services/foreground_upload.service_test.dart +++ b/mobile/test/services/foreground_upload.service_test.dart @@ -24,7 +24,7 @@ void main() { late ForegroundUploadService sut; late MockUploadRepository mockUploadRepository; late MockStorageRepository mockStorageRepository; - late MockDriftBackupRepository mockBackupRepository; + late MockBackupRepository mockBackupRepository; late MockConnectivityApi mockConnectivityApi; late MockAssetMediaRepository mockAssetMediaRepository; late Drift db; @@ -36,7 +36,7 @@ void main() { (MethodCall methodCall) async => 'test', ); db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db)); + await StoreService.init(storeRepository: StoreRepository(db)); await SettingsRepository.ensureInitialized(db); await Store.put(StoreKey.serverEndpoint, 'http://demo.immich.app'); @@ -49,7 +49,7 @@ void main() { setUp(() { mockUploadRepository = MockUploadRepository(); mockStorageRepository = MockStorageRepository(); - mockBackupRepository = MockDriftBackupRepository(); + mockBackupRepository = MockBackupRepository(); mockConnectivityApi = MockConnectivityApi(); mockAssetMediaRepository = MockAssetMediaRepository(); diff --git a/mobile/test/services/view_intent_asset_resolver_test.dart b/mobile/test/services/view_intent_asset_resolver_test.dart index 38d2f71f88..36c343180a 100644 --- a/mobile/test/services/view_intent_asset_resolver_test.dart +++ b/mobile/test/services/view_intent_asset_resolver_test.dart @@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/platform/view_intent_api.g.dart'; -import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/services/view_intent_asset_resolver.service.dart'; import 'package:mocktail/mocktail.dart'; @@ -16,13 +16,13 @@ import '../infrastructure/repository.mock.dart'; class MockTimelineFactory extends Mock implements TimelineFactory {} void main() { - late MockDriftLocalAssetRepository mockLocalAssetRepository; + late MockLocalAssetRepository mockLocalAssetRepository; late MockTimelineFactory timelineFactory; late List createdTimelineServices; late ProviderContainer container; setUp(() { - mockLocalAssetRepository = MockDriftLocalAssetRepository(); + mockLocalAssetRepository = MockLocalAssetRepository(); timelineFactory = MockTimelineFactory(); createdTimelineServices = []; @@ -33,9 +33,12 @@ void main() { return timelineService; }); + final drift = MockDrift(); + when(() => drift.localAssetRepository).thenReturn(mockLocalAssetRepository); + container = ProviderContainer( overrides: [ - localAssetRepository.overrideWith((ref) => mockLocalAssetRepository), + driftProvider.overrideWithValue(drift), timelineFactoryProvider.overrideWith((ref) => timelineFactory), ], ); diff --git a/mobile/test/test_utils/medium_factory.dart b/mobile/test/test_utils/medium_factory.dart index c8c41bbf0f..fa40de0160 100644 --- a/mobile/test/test_utils/medium_factory.dart +++ b/mobile/test/test_utils/medium_factory.dart @@ -31,8 +31,8 @@ class MediumFactory { T getRepository() { switch (T) { - case const (DriftLocalAlbumRepository): - return DriftLocalAlbumRepository(_db) as T; + case const (LocalAlbumRepository): + return LocalAlbumRepository(_db) as T; default: throw Exception('Unknown repository: $T'); } diff --git a/mobile/test/unit/factories/local_album_factory.dart b/mobile/test/unit/factories/local_album_factory.dart index 447001f971..639237c858 100644 --- a/mobile/test/unit/factories/local_album_factory.dart +++ b/mobile/test/unit/factories/local_album_factory.dart @@ -14,10 +14,10 @@ class LocalAlbumFactory { String? linkedRemoteAlbumId, int? assetCount, }) { - id = TestUtils.uuid(id); + final albumId = TestUtils.uuid(id); return LocalAlbum( - id: id, - name: name ?? 'local_album_$id', + id: albumId, + name: name ?? 'local_album_$albumId', updatedAt: TestUtils.date(updatedAt), backupSelection: backupSelection ?? .none, isIosSharedAlbum: isIosSharedAlbum ?? false, diff --git a/mobile/test/unit/factories/local_asset_factory.dart b/mobile/test/unit/factories/local_asset_factory.dart index bebe24e5b9..5b16635d48 100644 --- a/mobile/test/unit/factories/local_asset_factory.dart +++ b/mobile/test/unit/factories/local_asset_factory.dart @@ -6,11 +6,11 @@ class LocalAssetFactory { const LocalAssetFactory(); static LocalAsset create({String? id, String? name, String? remoteId}) { - id = TestUtils.uuid(id); + final assetId = TestUtils.uuid(id); return LocalAsset( - id: id, - name: name ?? 'local_$id.jpg', + id: assetId, + name: name ?? 'local_$assetId.jpg', remoteId: remoteId, type: AssetType.image, createdAt: TestUtils.yesterday(), diff --git a/mobile/test/unit/factories/partner_user_factory.dart b/mobile/test/unit/factories/partner_user_factory.dart index 63f94608ad..2ee0fa0773 100644 --- a/mobile/test/unit/factories/partner_user_factory.dart +++ b/mobile/test/unit/factories/partner_user_factory.dart @@ -6,11 +6,11 @@ class PartnerFactory { const PartnerFactory(); static Partner create({String? id, String? email, String? name, bool? inTimeline}) { - id = TestUtils.uuid(id); + final partnerId = TestUtils.uuid(id); return Partner( - id: id, - email: email ?? '$id@test.com', - name: name ?? 'user_$id', + id: partnerId, + email: email ?? '$partnerId@test.com', + name: name ?? 'user_$partnerId', inTimeline: inTimeline ?? false, hasProfileImage: false, profileChangedAt: DateTime.now(), diff --git a/mobile/test/unit/factories/remote_album_factory.dart b/mobile/test/unit/factories/remote_album_factory.dart index bffe62b651..70976b4485 100644 --- a/mobile/test/unit/factories/remote_album_factory.dart +++ b/mobile/test/unit/factories/remote_album_factory.dart @@ -19,10 +19,10 @@ class RemoteAlbumFactory { String? ownerName, bool isShared = false, }) { - id = TestUtils.uuid(id); + final albumId = TestUtils.uuid(id); return RemoteAlbum( - id: id, - name: name ?? 'remote_album_$id', + id: albumId, + name: name ?? 'remote_album_$albumId', ownerId: TestUtils.uuid(ownerId), description: description ?? '', createdAt: TestUtils.date(createdAt), @@ -31,7 +31,7 @@ class RemoteAlbumFactory { isActivityEnabled: isActivityEnabled, order: order, assetCount: assetCount, - ownerName: ownerName ?? 'owner_$id', + ownerName: ownerName ?? 'owner_$albumId', isShared: isShared, ); } diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart index 0876b9d793..2936708ab4 100644 --- a/mobile/test/unit/factories/remote_asset_factory.dart +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -16,13 +16,13 @@ class RemoteAssetFactory { DateTime? deletedAt, String? localId, }) { - id = TestUtils.uuid(id); + final assetId = TestUtils.uuid(id); return RemoteAsset( - id: id, - name: name ?? 'remote_$id.jpg', + id: assetId, + name: name ?? 'remote_$assetId.jpg', ownerId: TestUtils.uuid(ownerId), - checksum: 'checksum-$id', + checksum: 'checksum-$assetId', type: type, createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), diff --git a/mobile/test/unit/factories/user_factory.dart b/mobile/test/unit/factories/user_factory.dart index 248e98dc69..d3df9556ef 100644 --- a/mobile/test/unit/factories/user_factory.dart +++ b/mobile/test/unit/factories/user_factory.dart @@ -13,11 +13,11 @@ class UserFactory { bool? hasProfileImage, AvatarColor? avatarColor, }) { - id = TestUtils.uuid(id); + final userId = TestUtils.uuid(id); return User( - id: id, - name: name ?? 'user_$id', - email: email ?? '$id@test.com', + id: userId, + name: name ?? 'user_$userId', + email: email ?? '$userId@test.com', profileChangedAt: TestUtils.date(profileChangedAt), hasProfileImage: hasProfileImage ?? false, avatarColor: avatarColor ?? .primary, @@ -32,11 +32,11 @@ class UserFactory { bool? hasProfileImage, AvatarColor? avatarColor, }) { - id = TestUtils.uuid(id); + final userId = TestUtils.uuid(id); return UserDto( - id: id, - name: name ?? 'user_$id', - email: email ?? '$id@test.com', + id: userId, + name: name ?? 'user_$userId', + email: email ?? '$userId@test.com', profileChangedAt: TestUtils.date(profileChangedAt), hasProfileImage: hasProfileImage ?? false, avatarColor: avatarColor ?? .primary, diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index 6c41320a85..754c20aa48 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -26,7 +26,7 @@ import 'factories/user_factory.dart'; class RepositoryMocks { final localAlbum = LocalAlbumRepositoryStub(MockLocalAlbumRepository()); - final localAsset = LocalAssetRepositoryStub(MockDriftLocalAssetRepository()); + final localAsset = LocalAssetRepositoryStub(MockLocalAssetRepository()); final remoteAsset = RemoteAssetRepositoryStub(MockRemoteAssetRepository()); final remoteExif = RemoteExifRepositoryStub(MockRemoteExifRepository()); final trashedAsset = MockTrashedLocalAssetRepository(); @@ -247,8 +247,8 @@ extension type const LocalAlbumRepositoryStub(MockLocalAlbumRepository repo) imp () => repo.getAssetsToHash(any()); } -extension type const LocalAssetRepositoryStub(MockDriftLocalAssetRepository repo) - implements Stub { +extension type const LocalAssetRepositoryStub(MockLocalAssetRepository repo) + implements Stub { Future Function() get reconcileHashesFromCloudId => () => repo.reconcileHashesFromCloudId(); @@ -265,7 +265,7 @@ extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo) () => repo.getAssetEdits(any()); Future Function() get update => - () => repo.update( + () => repo.updateAssets( any(), isFavorite: any(named: 'isFavorite'), visibility: any(named: 'visibility'), @@ -275,7 +275,7 @@ extension type const RemoteAssetRepositoryStub(MockRemoteAssetRepository repo) extension type const RemoteExifRepositoryStub(MockRemoteExifRepository repo) implements Stub { Future Function() get update => - () => repo.update( + () => repo.updateExif( any(), dateTimeOriginal: any(named: 'dateTimeOriginal'), timeZone: any(named: 'timeZone'), diff --git a/mobile/test/unit/presentation/actions/archive_action_test.dart b/mobile/test/unit/presentation/actions/archive_action_test.dart index 555d6af2ff..80f1556e39 100644 --- a/mobile/test/unit/presentation/actions/archive_action_test.dart +++ b/mobile/test/unit/presentation/actions/archive_action_test.dart @@ -92,6 +92,28 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing); }); + testWidgets('offers an undo that puts the archived assets back on the timeline', (tester) async { + final asset = owned(); + + await pumpArchive(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.timeline))).called(1); + }); + + testWidgets('offers an undo that re-archives the unarchived assets', (tester) async { + final asset = owned(visibility: .archive); + + await pumpArchive(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.archive))).called(1); + }); + testWidgets('is hidden inside the locked folder view', (tester) async { await tester.pumpTestWidget( context, diff --git a/mobile/test/unit/presentation/actions/delete_action_test.dart b/mobile/test/unit/presentation/actions/delete_action_test.dart index 29d81a5c2c..b68e2096da 100644 --- a/mobile/test/unit/presentation/actions/delete_action_test.dart +++ b/mobile/test/unit/presentation/actions/delete_action_test.dart @@ -105,6 +105,17 @@ void main() { verify(() => cleanupService.deleteLocalAssets(['local'])).called(1); verify(() => assetService.trash([asset.id])).called(1); }); + + testWidgets('offers an undo that restores the trashed assets', (tester) async { + final asset = owned(); + + await pumpDelete(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.restoreTrash([asset.id])).called(1); + }); }); group('permanent', () { @@ -118,6 +129,15 @@ void main() { verifyNever(() => assetService.trash(any())); }); + testWidgets('offers no undo for a permanent delete', (tester) async { + await pumpDelete(tester, {owned()}, trashEnabled: false); + await respondToDialog(tester, confirm: true); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsNothing); + }); + testWidgets('permanently deletes a merged asset and removes its device copy', (tester) async { final asset = owned(localId: 'local'); @@ -170,6 +190,17 @@ void main() { verifyNever(() => assetService.trash(any())); verifyNever(() => assetService.delete(any())); }); + + testWidgets('is labelled trash', (tester) async { + await tester.pumpTestWidget( + context, + const ActionButton(action: DeleteAction(source: .timeline)), + overrides: context.selected({LocalAssetFactory.create()}), + ); + + expect(find.text(StaticTranslations.instance.trash), findsOneWidget); + expect(find.text(StaticTranslations.instance.delete), findsNothing); + }); }); group('prompt handling', () { diff --git a/mobile/test/unit/presentation/actions/lock_action_test.dart b/mobile/test/unit/presentation/actions/lock_action_test.dart index 9bad0f5a43..9bbbcaed7a 100644 --- a/mobile/test/unit/presentation/actions/lock_action_test.dart +++ b/mobile/test/unit/presentation/actions/lock_action_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; @@ -94,6 +95,25 @@ void main() { verifyNever(() => assetService.deleteLocal(any())); }); + testWidgets('offers an undo that locks the unlocked assets again', (tester) async { + final asset = owned(visibility: .locked); + + await pumpLock(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.update([asset.id], visibility: const .some(.locked))).called(1); + }); + + testWidgets('offers no undo for locking', (tester) async { + await pumpLock(tester, {owned()}); + await tester.pumpAndSettle(); + + expect(find.byType(SnackBar), findsOneWidget); + expect(find.text('Undo'), findsNothing); + }); + testWidgets('clears the selection once the update succeeds', (tester) async { await pumpLock(tester, {owned()}); await tester.pumpAndSettle(); diff --git a/mobile/test/unit/presentation/actions/restore_action_test.dart b/mobile/test/unit/presentation/actions/restore_action_test.dart index 530e1b76e9..5c11d20403 100644 --- a/mobile/test/unit/presentation/actions/restore_action_test.dart +++ b/mobile/test/unit/presentation/actions/restore_action_test.dart @@ -62,6 +62,17 @@ void main() { expect(find.byType(ImmichIconButton), findsNothing, reason: 'an empty selection hides the action'); }); + testWidgets('offers an undo that puts the assets back in the trash', (tester) async { + final asset = owned(); + + await pumpRestore(tester, {asset}); + await tester.pumpAndSettle(); + await tester.tap(find.text('Undo')); + await tester.pump(); + + verify(() => assetService.trash([asset.id])).called(1); + }); + testWidgets('is hidden when no owned asset is trashed', (tester) async { await tester.pumpTestWidget( context, diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index 36ae8d087d..1d46260435 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -17,6 +17,7 @@ import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/presentation/actions/action.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; @@ -28,6 +29,7 @@ import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_ui/immich_ui.dart'; import 'package:mocktail/mocktail.dart'; +import '../../infrastructure/repository.mock.dart'; import '../../test_utils.dart'; import '../factories/user_factory.dart'; import '../mocks.dart'; @@ -49,6 +51,7 @@ class PresentationContext { final RepositoryMocks repository; List get overrides => [ + driftProvider.overrideWithValue(_mockDrift()), currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)), assetServiceProvider.overrideWithValue(service.asset.service), cleanupServiceProvider.overrideWithValue(service.cleanup.service), @@ -57,10 +60,15 @@ class PresentationContext { gCastServiceProvider.overrideWithValue(service.cast), serverInfoServiceProvider.overrideWithValue(service.serverInfo), inLockedViewProvider.overrideWithValue(false), - remoteAssetRepositoryProvider.overrideWithValue(repository.remoteAsset.repo), assetMediaRepositoryProvider.overrideWithValue(repository.assetMedia.api), ]; + Drift _mockDrift() { + final drift = MockDrift(); + when(() => drift.remoteAssetRepository).thenReturn(repository.remoteAsset.repo); + return drift; + } + List selected(Set assets) => [ multiSelectProvider.overrideWith( () => MultiSelectNotifier(MultiSelectState(selectedAssets: assets, lockedSelectionAssets: const {})), @@ -71,7 +79,7 @@ class PresentationContext { TestUtils.init(); if (_db == null) { final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); + await StoreService.init(storeRepository: StoreRepository(db), listenUpdates: false); await StoreService.I.put(StoreKey.serverEndpoint, serverEndpoint); _db = db; } diff --git a/mobile/test/unit/services/asset_service_test.dart b/mobile/test/unit/services/asset_service_test.dart index a09465701b..b857be1fcb 100644 --- a/mobile/test/unit/services/asset_service_test.dart +++ b/mobile/test/unit/services/asset_service_test.dart @@ -22,7 +22,7 @@ void main() { sut = AssetService( remoteRepository: remoteRepository, exifRepository: exifRepository, - localRepository: MockDriftLocalAssetRepository(), + localRepository: MockLocalAssetRepository(), apiRepository: apiRepository, mediaRepository: mocks.assetMedia.api, trashedLocalRepository: mocks.trashedAsset, @@ -37,9 +37,9 @@ void main() { await sut.update(ids, dateTime: const .some(picked)); verify(() => apiRepository.update(ids, dateTimeOriginal: const .some(picked))).called(1); - verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify(() => remoteRepository.updateAssets(ids, createdAt: .some(DateTime.parse(picked)))).called(1); verify( - () => exifRepository.update( + () => exifRepository.updateExif( ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .some('UTC+06:00'), @@ -51,9 +51,9 @@ void main() { const picked = '2026-01-05T08:00:00.000-05:30'; await sut.update(ids, dateTime: const .some(picked)); - verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify(() => remoteRepository.updateAssets(ids, createdAt: .some(DateTime.parse(picked)))).called(1); verify( - () => exifRepository.update( + () => exifRepository.updateExif( ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .some('UTC-05:30'), @@ -65,9 +65,9 @@ void main() { const picked = '2026-06-10T13:15:00.000Z'; await sut.update(ids, dateTime: const .some(picked)); - verify(() => remoteRepository.update(ids, createdAt: .some(DateTime.parse(picked)))).called(1); + verify(() => remoteRepository.updateAssets(ids, createdAt: .some(DateTime.parse(picked)))).called(1); verify( - () => exifRepository.update(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()), + () => exifRepository.updateExif(ids, dateTimeOriginal: .some(DateTime.parse(picked)), timeZone: const .none()), ).called(1); }); diff --git a/mobile/test/utils/image_url_builder_test.dart b/mobile/test/utils/image_url_builder_test.dart index 1845d38eeb..055af185f7 100644 --- a/mobile/test/utils/image_url_builder_test.dart +++ b/mobile/test/utils/image_url_builder_test.dart @@ -12,7 +12,7 @@ void main() { setUpAll(() async { final db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); + await StoreService.init(storeRepository: StoreRepository(db), listenUpdates: false); await StoreService.I.put(StoreKey.serverEndpoint, endpoint); }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 684448b9a7..81ffccbdb9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -333,6 +333,155 @@ "x-immich-state": "Stable" } }, + "/admin/config": { + "get": { + "description": "Retrieve admin configuration.", + "operationId": "getAdminConfig", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the admin configuration", + "tags": [ + "Config (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "adminConfig.read", + "x-immich-state": "Alpha" + }, + "put": { + "description": "Update the system configuration with a new system configuration.", + "operationId": "updateAdminConfig", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update the system configuration", + "tags": [ + "Config (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "adminConfig.update", + "x-immich-state": "Alpha" + } + }, + "/admin/config/defaults": { + "get": { + "description": "Retrieve the default value of every system configuration property.", + "operationId": "getAdminConfigDefaults", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the system configuration defaults", + "tags": [ + "Config (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "adminConfig.read", + "x-immich-state": "Alpha" + } + }, "/admin/database-backups": { "delete": { "description": "Delete a backup by its filename", @@ -1169,7 +1318,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" + "$ref": "#/components/schemas/AdminConfigSmtpDto" } } }, @@ -3264,6 +3413,318 @@ "x-immich-state": "Deprecated" } }, + "/api-keys/{id}/rotate": { + "post": { + "description": "Generates a new secret for an API key, immediately invalidating the previous one. The current user must own this API key.", + "operationId": "rotateApiKey", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiKeyCreateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Rotate an API key", + "tags": [ + "API keys" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + } + ], + "x-immich-permission": "apiKey.rotate" + } + }, + "/asset-files": { + "get": { + "description": "Returns all matching asset files.", + "operationId": "searchAssetFiles", + "parameters": [ + { + "name": "assetId", + "required": true, + "in": "query", + "description": "Asset ID to filter files by", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "isEdited", + "required": false, + "in": "query", + "description": "The file was generated from an edit", + "schema": { + "type": "boolean" + } + }, + { + "name": "isProgressive", + "required": false, + "in": "query", + "description": "The file is a progressively encoded JPEG", + "schema": { + "type": "boolean" + } + }, + { + "name": "isTransparent", + "required": false, + "in": "query", + "description": "The file is transparent", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "description": "Filter by type of file", + "schema": { + "$ref": "#/components/schemas/AssetFileType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetFileResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Search asset files", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}": { + "delete": { + "description": "Delete a file and remove it from the database.", + "operationId": "deleteAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Returns metadata about a specific asset file.", + "operationId": "getAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFileResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Alpha" + } + }, + "/asset-files/{id}/download": { + "get": { + "description": "Serve the contents of a specific asset file.", + "operationId": "downloadAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "assetFile.download", + "x-immich-state": "Alpha" + } + }, "/assets": { "delete": { "description": "Deletes multiple assets at the same time.", @@ -5660,6 +6121,447 @@ "x-immich-state": "Stable" } }, + "/cluster-groups/requests": { + "get": { + "description": "Retrieve the pending requests for the current user to join a cluster group.", + "operationId": "getClusterGroupRequests", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ClusterGroupRequestResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve cluster group requests", + "tags": [ + "Cluster groups" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ], + "x-immich-permission": "clusterGroupRequest.read" + } + }, + "/cluster-groups/requests/{id}": { + "delete": { + "description": "Delete a pending request to join a cluster group.", + "operationId": "deleteClusterGroupRequest", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Decline a cluster group request", + "tags": [ + "Cluster groups" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ], + "x-immich-permission": "clusterGroupRequest.delete" + } + }, + "/cluster-groups/requests/{id}/accept": { + "post": { + "description": "Join the cluster group the request was created for.", + "operationId": "acceptClusterGroupRequest", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Accept a cluster group request", + "tags": [ + "Cluster groups" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ], + "x-immich-permission": "clusterGroupRequest.create" + } + }, + "/cluster-groups/{id}/leave": { + "post": { + "description": "Move the current user into a new cluster group of their own.", + "operationId": "leaveClusterGroup", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Leave a cluster group", + "tags": [ + "Cluster groups" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ], + "x-immich-permission": "clusterGroup.leave" + } + }, + "/cluster-groups/{id}/requests": { + "get": { + "description": "Retrieve the pending requests for other users to join the cluster group.", + "operationId": "getClusterGroupRequestsForGroup", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ClusterGroupRequestResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve the requests sent by a cluster group", + "tags": [ + "Cluster groups" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ], + "x-immich-permission": "clusterGroupRequest.read" + }, + "put": { + "description": "Ask another user to join the cluster group of the current user.", + "operationId": "createClusterGroupRequest", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterGroupRequestCreateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClusterGroupRequestResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a cluster group request", + "tags": [ + "Cluster groups" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ], + "x-immich-permission": "clusterGroupRequest.create" + } + }, + "/cluster-groups/{id}/users": { + "get": { + "description": "Retrieve the users that are a member of the cluster group.", + "operationId": "getClusterGroupUsers", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/UserResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve the users of a cluster group", + "tags": [ + "Cluster groups" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ], + "x-immich-permission": "clusterGroup.read" + } + }, + "/config": { + "get": { + "description": "Retrieve the system configuration properties that are visible to logged in users.", + "operationId": "getUserConfig", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the configuration with user visibility", + "tags": [ + "Config (user)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "userConfig.read", + "x-immich-state": "Alpha" + } + }, + "/config/defaults": { + "get": { + "description": "Retrieve the default value of the configuration properties that are visible to logged in users.", + "operationId": "getUserConfigDefaults", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConfigDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get the default configuration with user visibility", + "tags": [ + "Config (user)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-permission": "userConfig.read", + "x-immich-state": "Alpha" + } + }, "/download/archive": { "post": { "description": "Download a ZIP archive containing the specified assets. The assets must have been previously requested via the \"getDownloadInfo\" endpoint.", @@ -9777,6 +10679,74 @@ "x-immich-permission": "plugin.read" } }, + "/public/config": { + "get": { + "description": "Retrieve the system configuration properties that are visible to everyone.", + "operationId": "getPublicConfig", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicConfigDto" + } + } + }, + "description": "" + } + }, + "summary": "Get the public configuration", + "tags": [ + "Config (public)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-state": "Alpha" + } + }, + "/public/config/defaults": { + "get": { + "description": "Retrieve the default value of the configuration properties that are visible to everyone.", + "operationId": "getPublicConfigDefaults", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicConfigDto" + } + } + }, + "description": "" + } + }, + "summary": "Get the public configuration defaults", + "tags": [ + "Config (public)" + ], + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Alpha" + } + ], + "x-immich-state": "Alpha" + } + }, "/queues": { "get": { "description": "Retrieves a list of queues.", @@ -11213,6 +12183,7 @@ }, "/server/config": { "get": { + "deprecated": true, "description": "Retrieve the current server configuration.", "operationId": "getServerConfig", "parameters": [], @@ -11230,7 +12201,8 @@ }, "summary": "Get config", "tags": [ - "Server" + "Server", + "Deprecated" ], "x-immich-history": [ { @@ -11244,13 +12216,19 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getPublicConfig" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/server/features": { "get": { + "deprecated": true, "description": "Retrieve available features supported by this server.", "operationId": "getServerFeatures", "parameters": [], @@ -11268,7 +12246,8 @@ }, "summary": "Get features", "tags": [ - "Server" + "Server", + "Deprecated" ], "x-immich-history": [ { @@ -11282,9 +12261,14 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getPublicConfig" } ], - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/server/license": { @@ -13327,6 +14311,7 @@ }, "/system-config": { "get": { + "deprecated": true, "description": "Retrieve the current system configuration.", "operationId": "getConfig", "parameters": [], @@ -13335,7 +14320,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13355,7 +14340,8 @@ ], "summary": "Get system configuration", "tags": [ - "System config" + "System config", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -13370,12 +14356,18 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getAdminConfig" } ], "x-immich-permission": "systemConfig.read", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" }, "put": { + "deprecated": true, "description": "Update the system configuration with a new system configuration.", "operationId": "updateConfig", "parameters": [], @@ -13383,7 +14375,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13394,7 +14386,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13414,7 +14406,8 @@ ], "summary": "Update system configuration", "tags": [ - "System config" + "System config", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -13429,14 +14422,20 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "updateAdminConfig" } ], "x-immich-permission": "systemConfig.update", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/system-config/defaults": { "get": { + "deprecated": true, "description": "Retrieve the default values for the system configuration.", "operationId": "getConfigDefaults", "parameters": [], @@ -13445,7 +14444,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SystemConfigDto" + "$ref": "#/components/schemas/AdminConfigDto" } } }, @@ -13465,7 +14464,8 @@ ], "summary": "Get system configuration defaults", "tags": [ - "System config" + "System config", + "Deprecated" ], "x-immich-admin-only": true, "x-immich-history": [ @@ -13480,10 +14480,15 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated", + "replacementId": "getAdminConfigDefaults" } ], "x-immich-permission": "systemConfig.read", - "x-immich-state": "Stable" + "x-immich-state": "Deprecated" } }, "/system-config/storage-template-options": { @@ -15895,6 +16900,15 @@ "type": "string" } }, + { + "name": "logging", + "required": false, + "in": "query", + "description": "Workflow logs run results", + "schema": { + "type": "boolean" + } + }, { "name": "name", "required": false, @@ -16208,6 +17222,95 @@ "x-immich-state": "Deprecated" } }, + "/workflows/{id}/logs": { + "get": { + "description": "Retrieve logs of a workflows runs by ID", + "operationId": "getWorkflowLogs", + "parameters": [ + { + "name": "before", + "required": false, + "in": "query", + "description": "Filter by runs before a date/time", + "schema": { + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "example": "2024-01-01T00:00:00.000Z", + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "description": "Maximum number of logs", + "schema": { + "maximum": 9007199254740991, + "exclusiveMinimum": true, + "default": 50, + "type": "integer", + "minimum": 0 + } + }, + { + "name": "result", + "required": false, + "in": "query", + "description": "Filter by run result", + "schema": { + "$ref": "#/components/schemas/WorkflowResult" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowLogEntryDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve workflow logs", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ], + "x-immich-permission": "workflow.logs" + } + }, "/workflows/{id}/share": { "get": { "description": "Retrieve a workflow details without ids, default values, etc.", @@ -16284,6 +17387,10 @@ "name": "Assets", "description": "An asset is an image or video that has been uploaded to Immich." }, + { + "name": "Asset files", + "description": "An asset file is a file associated with an asset, including edited versions, thumbnails, etc." + }, { "name": "Authentication", "description": "Endpoints related to user authentication, including OAuth." @@ -16292,6 +17399,22 @@ "name": "Authentication (admin)", "description": "Administrative endpoints related to authentication." }, + { + "name": "Cluster groups", + "description": "A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them." + }, + { + "name": "Config (user)", + "description": "The system configuration properties that are visible to logged in users." + }, + { + "name": "Config (admin)", + "description": "Endpoints to view and modify the full system configuration." + }, + { + "name": "Config (public)", + "description": "The system configuration properties that are visible to everyone." + }, { "name": "Database Backups (admin)", "description": "Manage backups of the Immich database." @@ -16555,6 +17678,1216 @@ ], "type": "object" }, + "AdminConfigBackupsDto": { + "properties": { + "database": { + "$ref": "#/components/schemas/AdminConfigDatabaseBackupDto" + } + }, + "required": [ + "database" + ], + "type": "object" + }, + "AdminConfigClipDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "modelName": { + "description": "Name of the model to use", + "type": "string" + } + }, + "required": [ + "enabled", + "modelName" + ], + "type": "object" + }, + "AdminConfigDatabaseBackupDto": { + "properties": { + "cronExpression": { + "description": "Cron expression", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "keepLastAmount": { + "description": "Keep last amount", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "cronExpression", + "enabled", + "keepLastAmount" + ], + "type": "object" + }, + "AdminConfigDto": { + "description": "Configuration properties that are visible to the admin", + "properties": { + "backup": { + "$ref": "#/components/schemas/AdminConfigBackupsDto" + }, + "ffmpeg": { + "$ref": "#/components/schemas/AdminConfigFFmpegDto" + }, + "image": { + "$ref": "#/components/schemas/AdminConfigImageDto" + }, + "integrityChecks": { + "$ref": "#/components/schemas/AdminConfigIntegrityChecksDto" + }, + "job": { + "$ref": "#/components/schemas/AdminConfigJobDto" + }, + "library": { + "$ref": "#/components/schemas/AdminConfigLibraryDto" + }, + "logging": { + "$ref": "#/components/schemas/AdminConfigLoggingDto" + }, + "machineLearning": { + "$ref": "#/components/schemas/AdminConfigMachineLearningDto" + }, + "map": { + "$ref": "#/components/schemas/AdminConfigMapDto" + }, + "metadata": { + "$ref": "#/components/schemas/AdminConfigMetadataDto" + }, + "newVersionCheck": { + "$ref": "#/components/schemas/AdminConfigNewVersionCheckDto" + }, + "nightlyTasks": { + "$ref": "#/components/schemas/AdminConfigNightlyTasksDto" + }, + "notifications": { + "$ref": "#/components/schemas/AdminConfigNotificationsDto" + }, + "oauth": { + "$ref": "#/components/schemas/AdminConfigOAuthDto" + }, + "passwordLogin": { + "$ref": "#/components/schemas/AdminConfigPasswordLoginDto" + }, + "reverseGeocoding": { + "$ref": "#/components/schemas/AdminConfigReverseGeocodingDto" + }, + "server": { + "$ref": "#/components/schemas/AdminConfigServerDto" + }, + "storageTemplate": { + "$ref": "#/components/schemas/AdminConfigStorageTemplateDto" + }, + "templates": { + "$ref": "#/components/schemas/AdminConfigTemplatesDto" + }, + "theme": { + "$ref": "#/components/schemas/AdminConfigThemeDto" + }, + "trash": { + "$ref": "#/components/schemas/AdminConfigTrashDto" + }, + "user": { + "$ref": "#/components/schemas/AdminConfigUserDto" + } + }, + "required": [ + "backup", + "ffmpeg", + "image", + "integrityChecks", + "job", + "library", + "logging", + "machineLearning", + "map", + "metadata", + "newVersionCheck", + "nightlyTasks", + "notifications", + "oauth", + "passwordLogin", + "reverseGeocoding", + "server", + "storageTemplate", + "templates", + "theme", + "trash", + "user" + ], + "type": "object" + }, + "AdminConfigDuplicateDetectionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "maxDistance": { + "description": "Maximum distance threshold for duplicate detection", + "format": "double", + "maximum": 0.1, + "minimum": 0.001, + "type": "number" + } + }, + "required": [ + "enabled", + "maxDistance" + ], + "type": "object" + }, + "AdminConfigFFmpegDto": { + "properties": { + "accel": { + "$ref": "#/components/schemas/TranscodeHWAccel" + }, + "accelDecode": { + "description": "Accelerated decode", + "type": "boolean" + }, + "acceptedAudioCodecs": { + "description": "Accepted audio codecs", + "items": { + "$ref": "#/components/schemas/AudioCodec" + }, + "type": "array" + }, + "acceptedContainers": { + "description": "Accepted containers", + "items": { + "$ref": "#/components/schemas/VideoContainer" + }, + "type": "array" + }, + "acceptedVideoCodecs": { + "description": "Accepted video codecs", + "items": { + "$ref": "#/components/schemas/VideoCodec" + }, + "type": "array" + }, + "bframes": { + "description": "B-frames", + "maximum": 16, + "minimum": -1, + "type": "integer" + }, + "cqMode": { + "$ref": "#/components/schemas/CQMode" + }, + "crf": { + "description": "CRF", + "maximum": 51, + "minimum": 0, + "type": "integer" + }, + "gopSize": { + "description": "GOP size", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "maxBitrate": { + "description": "Max bitrate", + "type": "string" + }, + "preferredHwDevice": { + "description": "Preferred hardware device", + "type": "string" + }, + "preset": { + "description": "Preset", + "type": "string" + }, + "realtime": { + "$ref": "#/components/schemas/AdminConfigFFmpegRealtimeDto" + }, + "refs": { + "description": "References", + "maximum": 6, + "minimum": 0, + "type": "integer" + }, + "targetAudioCodec": { + "$ref": "#/components/schemas/AudioCodec" + }, + "targetResolution": { + "description": "Target resolution", + "type": "string" + }, + "targetVideoCodec": { + "$ref": "#/components/schemas/VideoCodec" + }, + "temporalAQ": { + "description": "Temporal AQ", + "type": "boolean" + }, + "threads": { + "description": "Threads", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "tonemap": { + "$ref": "#/components/schemas/ToneMapping" + }, + "transcode": { + "$ref": "#/components/schemas/TranscodePolicy" + }, + "twoPass": { + "description": "Two pass", + "type": "boolean" + } + }, + "required": [ + "accel", + "accelDecode", + "acceptedAudioCodecs", + "acceptedContainers", + "acceptedVideoCodecs", + "bframes", + "cqMode", + "crf", + "gopSize", + "maxBitrate", + "preferredHwDevice", + "preset", + "realtime", + "refs", + "targetAudioCodec", + "targetResolution", + "targetVideoCodec", + "temporalAQ", + "threads", + "tonemap", + "transcode", + "twoPass" + ], + "type": "object" + }, + "AdminConfigFFmpegRealtimeDto": { + "properties": { + "enabled": { + "description": "Enable real-time HLS transcoding (alpha)", + "type": "boolean" + }, + "resolutions": { + "description": "Resolutions to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/HlsVideoResolution" + }, + "type": "array" + }, + "videoCodecs": { + "description": "Video codecs to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/VideoCodec" + }, + "type": "array" + } + }, + "required": [ + "enabled", + "resolutions", + "videoCodecs" + ], + "type": "object" + }, + "AdminConfigFacesDto": { + "properties": { + "import": { + "description": "Import", + "type": "boolean" + } + }, + "required": [ + "import" + ], + "type": "object" + }, + "AdminConfigFacialRecognitionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "maxDistance": { + "description": "Maximum distance threshold for face recognition", + "format": "double", + "maximum": 2, + "minimum": 0.1, + "type": "number" + }, + "minFaces": { + "description": "Minimum number of faces required for recognition", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "minScore": { + "description": "Minimum confidence score for face detection", + "format": "double", + "maximum": 1, + "minimum": 0.1, + "type": "number" + }, + "modelName": { + "description": "Name of the model to use", + "type": "string" + } + }, + "required": [ + "enabled", + "maxDistance", + "minFaces", + "minScore", + "modelName" + ], + "type": "object" + }, + "AdminConfigGeneratedFullsizeImageDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "progressive": { + "description": "Progressive", + "type": "boolean" + }, + "quality": { + "description": "Quality", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "enabled", + "format", + "quality" + ], + "type": "object" + }, + "AdminConfigGeneratedImageDto": { + "properties": { + "format": { + "$ref": "#/components/schemas/ImageFormat" + }, + "progressive": { + "description": "Progressive", + "type": "boolean" + }, + "quality": { + "description": "Quality", + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "size": { + "description": "Size", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "format", + "quality", + "size" + ], + "type": "object" + }, + "AdminConfigImageDto": { + "properties": { + "colorspace": { + "$ref": "#/components/schemas/Colorspace" + }, + "extractEmbedded": { + "description": "Extract embedded", + "type": "boolean" + }, + "fullsize": { + "$ref": "#/components/schemas/AdminConfigGeneratedFullsizeImageDto" + }, + "preview": { + "$ref": "#/components/schemas/AdminConfigGeneratedImageDto" + }, + "thumbnail": { + "$ref": "#/components/schemas/AdminConfigGeneratedImageDto" + } + }, + "required": [ + "colorspace", + "extractEmbedded", + "fullsize", + "preview", + "thumbnail" + ], + "type": "object" + }, + "AdminConfigIntegrityChecksDto": { + "description": "Integrity checks config", + "properties": { + "checksumFiles": { + "$ref": "#/components/schemas/AdminConfigIntegrityChecksumJobDto" + }, + "missingFiles": { + "$ref": "#/components/schemas/AdminConfigIntegrityJobDto" + }, + "untrackedFiles": { + "$ref": "#/components/schemas/AdminConfigIntegrityJobDto" + } + }, + "required": [ + "checksumFiles", + "missingFiles", + "untrackedFiles" + ], + "type": "object" + }, + "AdminConfigIntegrityChecksumJobDto": { + "description": "Integrity checksum job config", + "properties": { + "cronExpression": { + "description": "Cron expression for when the integrity check should run", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "percentageLimit": { + "description": "Percentage limit of the integrity checksum job", + "format": "double", + "maximum": 1, + "minimum": 0, + "type": "number" + }, + "timeLimit": { + "description": "How long the integrity checksum job may run for", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "cronExpression", + "enabled", + "percentageLimit", + "timeLimit" + ], + "type": "object" + }, + "AdminConfigIntegrityJobDto": { + "description": "Integrity job config", + "properties": { + "cronExpression": { + "description": "Cron expression for when the integrity check should run", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "cronExpression", + "enabled" + ], + "type": "object" + }, + "AdminConfigJobDto": { + "properties": { + "backgroundTask": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "editor": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "faceDetection": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "integrityCheck": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "library": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "metadataExtraction": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "migration": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "notifications": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "ocr": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "search": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "sidecar": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "smartSearch": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "thumbnailGeneration": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "videoConversion": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + }, + "workflow": { + "$ref": "#/components/schemas/AdminConfigJobSettingsDto" + } + }, + "required": [ + "backgroundTask", + "editor", + "faceDetection", + "integrityCheck", + "library", + "metadataExtraction", + "migration", + "notifications", + "ocr", + "search", + "sidecar", + "smartSearch", + "thumbnailGeneration", + "videoConversion", + "workflow" + ], + "type": "object" + }, + "AdminConfigJobSettingsDto": { + "properties": { + "concurrency": { + "description": "Concurrency", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "concurrency" + ], + "type": "object" + }, + "AdminConfigLibraryDto": { + "properties": { + "scan": { + "$ref": "#/components/schemas/AdminConfigLibraryScanDto" + }, + "watch": { + "$ref": "#/components/schemas/AdminConfigLibraryWatchDto" + } + }, + "required": [ + "scan", + "watch" + ], + "type": "object" + }, + "AdminConfigLibraryScanDto": { + "properties": { + "cronExpression": { + "description": "Cron expression", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "cronExpression", + "enabled" + ], + "type": "object" + }, + "AdminConfigLibraryWatchDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "AdminConfigLoggingDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "level": { + "$ref": "#/components/schemas/LogLevel" + } + }, + "required": [ + "enabled", + "level" + ], + "type": "object" + }, + "AdminConfigMachineLearningAvailabilityChecksDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "interval": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "timeout": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "enabled", + "interval", + "timeout" + ], + "type": "object" + }, + "AdminConfigMachineLearningDto": { + "properties": { + "availabilityChecks": { + "$ref": "#/components/schemas/AdminConfigMachineLearningAvailabilityChecksDto" + }, + "clip": { + "$ref": "#/components/schemas/AdminConfigClipDto" + }, + "duplicateDetection": { + "$ref": "#/components/schemas/AdminConfigDuplicateDetectionDto" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "facialRecognition": { + "$ref": "#/components/schemas/AdminConfigFacialRecognitionDto" + }, + "ocr": { + "$ref": "#/components/schemas/AdminConfigOcrDto" + }, + "urls": { + "description": "ML service URLs", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "availabilityChecks", + "clip", + "duplicateDetection", + "enabled", + "facialRecognition", + "ocr", + "urls" + ], + "type": "object" + }, + "AdminConfigMapDto": { + "properties": { + "darkStyle": { + "description": "Dark map style URL", + "format": "uri", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "lightStyle": { + "description": "Light map style URL", + "format": "uri", + "type": "string" + } + }, + "required": [ + "darkStyle", + "enabled", + "lightStyle" + ], + "type": "object" + }, + "AdminConfigMetadataDto": { + "properties": { + "faces": { + "$ref": "#/components/schemas/AdminConfigFacesDto" + } + }, + "required": [ + "faces" + ], + "type": "object" + }, + "AdminConfigNewVersionCheckDto": { + "properties": { + "channel": { + "$ref": "#/components/schemas/ReleaseChannel" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "channel", + "enabled" + ], + "type": "object" + }, + "AdminConfigNightlyTasksDto": { + "properties": { + "clusterNewFaces": { + "description": "Cluster new faces", + "type": "boolean" + }, + "databaseCleanup": { + "description": "Database cleanup", + "type": "boolean" + }, + "generateMemories": { + "description": "Generate memories", + "type": "boolean" + }, + "missingThumbnails": { + "description": "Missing thumbnails", + "type": "boolean" + }, + "startTime": { + "description": "Start time (HH:MM)", + "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$", + "type": "string" + }, + "syncQuotaUsage": { + "description": "Sync quota usage", + "type": "boolean" + } + }, + "required": [ + "clusterNewFaces", + "databaseCleanup", + "generateMemories", + "missingThumbnails", + "startTime", + "syncQuotaUsage" + ], + "type": "object" + }, + "AdminConfigNotificationsDto": { + "properties": { + "smtp": { + "$ref": "#/components/schemas/AdminConfigSmtpDto" + } + }, + "required": [ + "smtp" + ], + "type": "object" + }, + "AdminConfigOAuthDto": { + "properties": { + "accountManagementUrl": { + "default": "", + "description": "Account management URL", + "type": "string" + }, + "allowInsecureRequests": { + "description": "Allow insecure requests", + "type": "boolean" + }, + "autoLaunch": { + "description": "Auto launch", + "type": "boolean" + }, + "autoRegister": { + "description": "Auto register", + "type": "boolean" + }, + "buttonText": { + "description": "Button text", + "type": "string" + }, + "clientId": { + "description": "Client ID", + "type": "string" + }, + "clientSecret": { + "description": "Client secret", + "type": "string" + }, + "defaultStorageQuota": { + "description": "Default storage quota", + "maximum": 9007199254740991, + "minimum": 0, + "nullable": true, + "type": "integer" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "endSessionEndpoint": { + "description": "End session endpoint", + "type": "string" + }, + "issuerUrl": { + "description": "Issuer URL", + "type": "string" + }, + "mobileOverrideEnabled": { + "description": "Mobile override enabled", + "type": "boolean" + }, + "mobileRedirectUri": { + "description": "Mobile redirect URI (set to empty string to disable)", + "type": "string" + }, + "profileSigningAlgorithm": { + "description": "Profile signing algorithm", + "type": "string" + }, + "prompt": { + "description": "OAuth prompt parameter (e.g. select_account, login, consent)", + "type": "string" + }, + "roleClaim": { + "description": "Role claim", + "type": "string" + }, + "scope": { + "description": "Scope", + "type": "string" + }, + "signingAlgorithm": { + "description": "Signing algorithm", + "type": "string" + }, + "storageLabelClaim": { + "description": "Storage label claim", + "type": "string" + }, + "storageQuotaClaim": { + "description": "Storage quota claim", + "type": "string" + }, + "timeout": { + "description": "Timeout", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "tokenEndpointAuthMethod": { + "$ref": "#/components/schemas/OAuthTokenEndpointAuthMethod" + } + }, + "required": [ + "allowInsecureRequests", + "autoLaunch", + "autoRegister", + "buttonText", + "clientId", + "clientSecret", + "defaultStorageQuota", + "enabled", + "endSessionEndpoint", + "issuerUrl", + "mobileOverrideEnabled", + "mobileRedirectUri", + "profileSigningAlgorithm", + "prompt", + "roleClaim", + "scope", + "signingAlgorithm", + "storageLabelClaim", + "storageQuotaClaim", + "timeout", + "tokenEndpointAuthMethod" + ], + "type": "object" + }, + "AdminConfigOcrDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "maxResolution": { + "description": "Maximum resolution for OCR processing", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "minDetectionScore": { + "description": "Minimum confidence score for text detection", + "format": "double", + "maximum": 1, + "minimum": 0.1, + "type": "number" + }, + "minRecognitionScore": { + "description": "Minimum confidence score for text recognition", + "format": "double", + "maximum": 1, + "minimum": 0.1, + "type": "number" + }, + "modelName": { + "description": "Name of the model to use", + "type": "string" + } + }, + "required": [ + "enabled", + "maxResolution", + "minDetectionScore", + "minRecognitionScore", + "modelName" + ], + "type": "object" + }, + "AdminConfigPasswordLoginDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "AdminConfigReverseGeocodingDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "AdminConfigServerDto": { + "properties": { + "externalDomain": { + "description": "External domain", + "type": "string" + }, + "loginPageMessage": { + "description": "Login page message", + "type": "string" + }, + "publicUsers": { + "description": "Public users", + "type": "boolean" + } + }, + "required": [ + "externalDomain", + "loginPageMessage", + "publicUsers" + ], + "type": "object" + }, + "AdminConfigSmtpDto": { + "properties": { + "enabled": { + "description": "Whether SMTP email notifications are enabled", + "type": "boolean" + }, + "from": { + "description": "Email address to send from", + "type": "string" + }, + "replyTo": { + "description": "Email address for replies", + "type": "string" + }, + "transport": { + "$ref": "#/components/schemas/AdminConfigSmtpTransportDto" + } + }, + "required": [ + "enabled", + "from", + "replyTo", + "transport" + ], + "type": "object" + }, + "AdminConfigSmtpTransportDto": { + "properties": { + "host": { + "description": "SMTP server hostname", + "type": "string" + }, + "ignoreCert": { + "description": "Whether to ignore SSL certificate errors", + "type": "boolean" + }, + "password": { + "description": "SMTP password", + "type": "string" + }, + "port": { + "description": "SMTP server port", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "secure": { + "description": "Whether to use secure connection (TLS/SSL)", + "type": "boolean" + }, + "username": { + "description": "SMTP username", + "type": "string" + } + }, + "required": [ + "host", + "ignoreCert", + "password", + "port", + "secure", + "username" + ], + "type": "object" + }, + "AdminConfigStorageTemplateDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "hashVerificationEnabled": { + "description": "Hash verification enabled", + "type": "boolean" + }, + "template": { + "description": "Template", + "type": "string" + } + }, + "required": [ + "enabled", + "hashVerificationEnabled", + "template" + ], + "type": "object" + }, + "AdminConfigTemplateEmailsDto": { + "properties": { + "albumInviteTemplate": { + "description": "Album invite template", + "type": "string" + }, + "albumUpdateTemplate": { + "description": "Album update template", + "type": "string" + }, + "welcomeTemplate": { + "description": "Welcome template", + "type": "string" + } + }, + "required": [ + "albumInviteTemplate", + "albumUpdateTemplate", + "welcomeTemplate" + ], + "type": "object" + }, + "AdminConfigTemplatesDto": { + "properties": { + "email": { + "$ref": "#/components/schemas/AdminConfigTemplateEmailsDto" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "AdminConfigThemeDto": { + "properties": { + "customCss": { + "description": "Custom CSS for theming", + "type": "string" + } + }, + "required": [ + "customCss" + ], + "type": "object" + }, + "AdminConfigTrashDto": { + "properties": { + "days": { + "description": "Days", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "days", + "enabled" + ], + "type": "object" + }, + "AdminConfigUserDto": { + "properties": { + "deleteDelay": { + "description": "Delete delay", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "deleteDelay" + ], + "type": "object" + }, "AdminOnboardingUpdateDto": { "properties": { "isOnboarded": { @@ -16851,16 +19184,63 @@ "ApiKeyCreateResponseDto": { "properties": { "apiKey": { - "$ref": "#/components/schemas/ApiKeyResponseDto" + "$ref": "#/components/schemas/ApiKeyResponseDto", + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "API key ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "name": { + "description": "API key name", + "type": "string" + }, + "permissions": { + "description": "List of permissions", + "items": { + "$ref": "#/components/schemas/Permission" + }, + "type": "array" }, "secret": { "description": "API key secret (only shown once)", "type": "string" + }, + "updatedAt": { + "description": "Last update date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" } }, "required": [ "apiKey", - "secret" + "createdAt", + "id", + "name", + "permissions", + "secret", + "updatedAt" ], "type": "object" }, @@ -17435,6 +19815,71 @@ ], "type": "object" }, + "AssetFileResponseDto": { + "properties": { + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Asset file ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "isEdited": { + "description": "The file was generated from an edit", + "type": "boolean" + }, + "isProgressive": { + "description": "The file is a progressively encoded JPEG", + "type": "boolean" + }, + "isTransparent": { + "description": "The file is transparent", + "type": "boolean" + }, + "path": { + "description": "File path", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/AssetFileType" + }, + "updatedAt": { + "description": "Update date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "createdAt", + "id", + "isEdited", + "isProgressive", + "isTransparent", + "path", + "type", + "updatedAt" + ], + "type": "object" + }, + "AssetFileType": { + "description": "Type of file", + "enum": [ + "fullsize", + "preview", + "thumbnail", + "sidecar", + "encoded_video" + ], + "type": "string" + }, "AssetIdErrorReason": { "description": "Error reason if failed", "enum": [ @@ -18293,23 +20738,6 @@ ], "type": "object" }, - "CLIPConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "modelName": { - "description": "Name of the model to use", - "type": "string" - } - }, - "required": [ - "enabled", - "modelName" - ], - "type": "object" - }, "CQMode": { "description": "CQ mode", "enum": [ @@ -18423,6 +20851,56 @@ ], "type": "object" }, + "ClusterGroupRequestCreateDto": { + "properties": { + "userId": { + "description": "User to invite into the cluster group", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + "required": [ + "userId" + ], + "type": "object" + }, + "ClusterGroupRequestResponseDto": { + "properties": { + "clusterGroupId": { + "description": "Cluster group the user is invited to join", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "createdAt": { + "description": "Creation date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Request ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "userId": { + "description": "User the request was created for", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + "required": [ + "clusterGroupId", + "createdAt", + "id", + "userId" + ], + "type": "object" + }, "Colorspace": { "description": "Colorspace", "enum": [ @@ -18606,30 +21084,6 @@ ], "type": "object" }, - "DatabaseBackupConfig": { - "properties": { - "cronExpression": { - "description": "Cron expression", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "keepLastAmount": { - "description": "Keep last amount", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "cronExpression", - "enabled", - "keepLastAmount" - ], - "type": "object" - }, "DatabaseBackupDeleteDto": { "properties": { "backups": { @@ -18696,6 +21150,10 @@ }, "DownloadArchiveDto": { "properties": { + "archiveName": { + "description": "The name of the archive to download, without extension", + "type": "string" + }, "assetIds": { "description": "Asset IDs", "items": { @@ -18827,26 +21285,6 @@ }, "type": "object" }, - "DuplicateDetectionConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "maxDistance": { - "description": "Maximum distance threshold for duplicate detection", - "format": "double", - "maximum": 0.1, - "minimum": 0.001, - "type": "number" - } - }, - "required": [ - "enabled", - "maxDistance" - ], - "type": "object" - }, "DuplicateResolveDto": { "properties": { "groups": { @@ -19131,46 +21569,6 @@ ], "type": "object" }, - "FacialRecognitionConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "maxDistance": { - "description": "Maximum distance threshold for face recognition", - "format": "double", - "maximum": 2, - "minimum": 0.1, - "type": "number" - }, - "minFaces": { - "description": "Minimum number of faces required for recognition", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "minScore": { - "description": "Minimum confidence score for face detection", - "format": "double", - "maximum": 1, - "minimum": 0.1, - "type": "number" - }, - "modelName": { - "description": "Name of the model to use", - "type": "string" - } - }, - "required": [ - "enabled", - "maxDistance", - "minFaces", - "minScore", - "modelName" - ], - "type": "object" - }, "FoldersResponse": { "properties": { "enabled": { @@ -19374,20 +21772,6 @@ ], "type": "string" }, - "JobSettingsDto": { - "properties": { - "concurrency": { - "description": "Concurrency", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "concurrency" - ], - "type": "object" - }, "LibraryResponseDto": { "properties": { "assetCount": { @@ -19536,7 +21920,7 @@ "description": "User email", "example": "testuser@email.com", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "password": { @@ -19580,7 +21964,7 @@ "userEmail": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "userId": { @@ -19619,30 +22003,6 @@ ], "type": "object" }, - "MachineLearningAvailabilityChecksDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "interval": { - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "timeout": { - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - } - }, - "required": [ - "enabled", - "interval", - "timeout" - ], - "type": "object" - }, "MaintenanceAction": { "description": "Maintenance action", "enum": [ @@ -20508,6 +22868,7 @@ "SystemMessage", "AlbumInvite", "AlbumUpdate", + "ClusterGroupRequest", "Custom" ], "type": "string" @@ -20624,46 +22985,6 @@ ], "type": "string" }, - "OcrConfig": { - "properties": { - "enabled": { - "description": "Whether the task is enabled", - "type": "boolean" - }, - "maxResolution": { - "description": "Maximum resolution for OCR processing", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "minDetectionScore": { - "description": "Minimum confidence score for text detection", - "format": "double", - "maximum": 1, - "minimum": 0.1, - "type": "number" - }, - "minRecognitionScore": { - "description": "Minimum confidence score for text recognition", - "format": "double", - "maximum": 1, - "minimum": 0.1, - "type": "number" - }, - "modelName": { - "description": "Name of the model to use", - "type": "string" - } - }, - "required": [ - "enabled", - "maxResolution", - "minDetectionScore", - "minRecognitionScore", - "modelName" - ], - "type": "object" - }, "OnThisDayDto": { "properties": { "year": { @@ -20733,7 +23054,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "id": { @@ -20940,6 +23261,7 @@ "apiKey.read", "apiKey.update", "apiKey.delete", + "apiKey.rotate", "asset.read", "asset.update", "asset.delete", @@ -20950,6 +23272,9 @@ "asset.upload", "asset.copy", "asset.derive", + "assetFile.read", + "assetFile.delete", + "assetFile.download", "asset.edit.get", "asset.edit.create", "asset.edit.delete", @@ -20972,6 +23297,14 @@ "backup.download", "backup.upload", "backup.delete", + "clusterGroup.read", + "clusterGroup.leave", + "clusterGroupRequest.create", + "clusterGroupRequest.read", + "clusterGroupRequest.delete", + "adminConfig.read", + "adminConfig.update", + "userConfig.read", "duplicate.read", "duplicate.delete", "face.create", @@ -21079,6 +23412,7 @@ "workflow.read", "workflow.update", "workflow.delete", + "workflow.logs", "adminUser.create", "adminUser.read", "adminUser.update", @@ -21506,6 +23840,88 @@ ], "type": "object" }, + "PublicConfigDto": { + "description": "Configuration properties that are visible to everyone", + "properties": { + "oauth": { + "$ref": "#/components/schemas/PublicConfigOAuthDto" + }, + "passwordLogin": { + "$ref": "#/components/schemas/PublicConfigPasswordLoginDto" + }, + "server": { + "$ref": "#/components/schemas/PublicConfigServerDto" + }, + "theme": { + "$ref": "#/components/schemas/PublicConfigThemeDto" + } + }, + "required": [ + "oauth", + "passwordLogin", + "server", + "theme" + ], + "type": "object" + }, + "PublicConfigOAuthDto": { + "properties": { + "autoLaunch": { + "description": "Auto launch", + "type": "boolean" + }, + "buttonText": { + "description": "Button text", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "autoLaunch", + "buttonText", + "enabled" + ], + "type": "object" + }, + "PublicConfigPasswordLoginDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "PublicConfigServerDto": { + "properties": { + "loginPageMessage": { + "description": "Login page message", + "type": "string" + } + }, + "required": [ + "loginPageMessage" + ], + "type": "object" + }, + "PublicConfigThemeDto": { + "properties": { + "customCss": { + "description": "Custom CSS for theming", + "type": "string" + } + }, + "required": [ + "customCss" + ], + "type": "object" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { @@ -22540,6 +24956,11 @@ "minimum": -9007199254740991, "type": "integer" }, + "oauthAccountManagementUrl": { + "default": "", + "description": "OAuth account management URL", + "type": "string" + }, "oauthButtonText": { "description": "OAuth button text", "type": "string" @@ -23299,7 +25720,7 @@ "description": "User email", "example": "testuser@email.com", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "name": { @@ -25605,976 +28026,6 @@ ], "type": "object" }, - "SystemConfigBackupsDto": { - "properties": { - "database": { - "$ref": "#/components/schemas/DatabaseBackupConfig" - } - }, - "required": [ - "database" - ], - "type": "object" - }, - "SystemConfigDto": { - "description": "System configuration", - "properties": { - "backup": { - "$ref": "#/components/schemas/SystemConfigBackupsDto" - }, - "ffmpeg": { - "$ref": "#/components/schemas/SystemConfigFFmpegDto" - }, - "image": { - "$ref": "#/components/schemas/SystemConfigImageDto" - }, - "integrityChecks": { - "$ref": "#/components/schemas/SystemConfigIntegrityChecks" - }, - "job": { - "$ref": "#/components/schemas/SystemConfigJobDto" - }, - "library": { - "$ref": "#/components/schemas/SystemConfigLibraryDto" - }, - "logging": { - "$ref": "#/components/schemas/SystemConfigLoggingDto" - }, - "machineLearning": { - "$ref": "#/components/schemas/SystemConfigMachineLearningDto" - }, - "map": { - "$ref": "#/components/schemas/SystemConfigMapDto" - }, - "metadata": { - "$ref": "#/components/schemas/SystemConfigMetadataDto" - }, - "newVersionCheck": { - "$ref": "#/components/schemas/SystemConfigNewVersionCheckDto" - }, - "nightlyTasks": { - "$ref": "#/components/schemas/SystemConfigNightlyTasksDto" - }, - "notifications": { - "$ref": "#/components/schemas/SystemConfigNotificationsDto" - }, - "oauth": { - "$ref": "#/components/schemas/SystemConfigOAuthDto" - }, - "passwordLogin": { - "$ref": "#/components/schemas/SystemConfigPasswordLoginDto" - }, - "reverseGeocoding": { - "$ref": "#/components/schemas/SystemConfigReverseGeocodingDto" - }, - "server": { - "$ref": "#/components/schemas/SystemConfigServerDto" - }, - "storageTemplate": { - "$ref": "#/components/schemas/SystemConfigStorageTemplateDto" - }, - "templates": { - "$ref": "#/components/schemas/SystemConfigTemplatesDto" - }, - "theme": { - "$ref": "#/components/schemas/SystemConfigThemeDto" - }, - "trash": { - "$ref": "#/components/schemas/SystemConfigTrashDto" - }, - "user": { - "$ref": "#/components/schemas/SystemConfigUserDto" - } - }, - "required": [ - "backup", - "ffmpeg", - "image", - "integrityChecks", - "job", - "library", - "logging", - "machineLearning", - "map", - "metadata", - "newVersionCheck", - "nightlyTasks", - "notifications", - "oauth", - "passwordLogin", - "reverseGeocoding", - "server", - "storageTemplate", - "templates", - "theme", - "trash", - "user" - ], - "type": "object" - }, - "SystemConfigFFmpegDto": { - "properties": { - "accel": { - "$ref": "#/components/schemas/TranscodeHWAccel" - }, - "accelDecode": { - "description": "Accelerated decode", - "type": "boolean" - }, - "acceptedAudioCodecs": { - "description": "Accepted audio codecs", - "items": { - "$ref": "#/components/schemas/AudioCodec" - }, - "type": "array" - }, - "acceptedContainers": { - "description": "Accepted containers", - "items": { - "$ref": "#/components/schemas/VideoContainer" - }, - "type": "array" - }, - "acceptedVideoCodecs": { - "description": "Accepted video codecs", - "items": { - "$ref": "#/components/schemas/VideoCodec" - }, - "type": "array" - }, - "bframes": { - "description": "B-frames", - "maximum": 16, - "minimum": -1, - "type": "integer" - }, - "cqMode": { - "$ref": "#/components/schemas/CQMode" - }, - "crf": { - "description": "CRF", - "maximum": 51, - "minimum": 0, - "type": "integer" - }, - "gopSize": { - "description": "GOP size", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "maxBitrate": { - "description": "Max bitrate", - "type": "string" - }, - "preferredHwDevice": { - "description": "Preferred hardware device", - "type": "string" - }, - "preset": { - "description": "Preset", - "type": "string" - }, - "realtime": { - "$ref": "#/components/schemas/SystemConfigFFmpegRealtimeDto" - }, - "refs": { - "description": "References", - "maximum": 6, - "minimum": 0, - "type": "integer" - }, - "targetAudioCodec": { - "$ref": "#/components/schemas/AudioCodec" - }, - "targetResolution": { - "description": "Target resolution", - "type": "string" - }, - "targetVideoCodec": { - "$ref": "#/components/schemas/VideoCodec" - }, - "temporalAQ": { - "description": "Temporal AQ", - "type": "boolean" - }, - "threads": { - "description": "Threads", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "tonemap": { - "$ref": "#/components/schemas/ToneMapping" - }, - "transcode": { - "$ref": "#/components/schemas/TranscodePolicy" - }, - "twoPass": { - "description": "Two pass", - "type": "boolean" - } - }, - "required": [ - "accel", - "accelDecode", - "acceptedAudioCodecs", - "acceptedContainers", - "acceptedVideoCodecs", - "bframes", - "cqMode", - "crf", - "gopSize", - "maxBitrate", - "preferredHwDevice", - "preset", - "realtime", - "refs", - "targetAudioCodec", - "targetResolution", - "targetVideoCodec", - "temporalAQ", - "threads", - "tonemap", - "transcode", - "twoPass" - ], - "type": "object" - }, - "SystemConfigFFmpegRealtimeDto": { - "properties": { - "enabled": { - "description": "Enable real-time HLS transcoding (alpha)", - "type": "boolean" - }, - "resolutions": { - "description": "Resolutions to use for real-time HLS transcoding", - "items": { - "$ref": "#/components/schemas/HlsVideoResolution" - }, - "type": "array" - }, - "videoCodecs": { - "description": "Video codecs to use for real-time HLS transcoding", - "items": { - "$ref": "#/components/schemas/VideoCodec" - }, - "type": "array" - } - }, - "required": [ - "enabled", - "resolutions", - "videoCodecs" - ], - "type": "object" - }, - "SystemConfigFacesDto": { - "properties": { - "import": { - "description": "Import", - "type": "boolean" - } - }, - "required": [ - "import" - ], - "type": "object" - }, - "SystemConfigGeneratedFullsizeImageDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "format": { - "$ref": "#/components/schemas/ImageFormat" - }, - "progressive": { - "description": "Progressive", - "type": "boolean" - }, - "quality": { - "description": "Quality", - "maximum": 100, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "enabled", - "format", - "quality" - ], - "type": "object" - }, - "SystemConfigGeneratedImageDto": { - "properties": { - "format": { - "$ref": "#/components/schemas/ImageFormat" - }, - "progressive": { - "description": "Progressive", - "type": "boolean" - }, - "quality": { - "description": "Quality", - "maximum": 100, - "minimum": 1, - "type": "integer" - }, - "size": { - "description": "Size", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "format", - "quality", - "size" - ], - "type": "object" - }, - "SystemConfigImageDto": { - "properties": { - "colorspace": { - "$ref": "#/components/schemas/Colorspace" - }, - "extractEmbedded": { - "description": "Extract embedded", - "type": "boolean" - }, - "fullsize": { - "$ref": "#/components/schemas/SystemConfigGeneratedFullsizeImageDto" - }, - "preview": { - "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" - }, - "thumbnail": { - "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" - } - }, - "required": [ - "colorspace", - "extractEmbedded", - "fullsize", - "preview", - "thumbnail" - ], - "type": "object" - }, - "SystemConfigIntegrityChecks": { - "description": "Integrity checks config", - "properties": { - "checksumFiles": { - "$ref": "#/components/schemas/SystemConfigIntegrityChecksumJob" - }, - "missingFiles": { - "$ref": "#/components/schemas/SystemConfigIntegrityJob" - }, - "untrackedFiles": { - "$ref": "#/components/schemas/SystemConfigIntegrityJob" - } - }, - "required": [ - "checksumFiles", - "missingFiles", - "untrackedFiles" - ], - "type": "object" - }, - "SystemConfigIntegrityChecksumJob": { - "description": "Integrity checksum job config", - "properties": { - "cronExpression": { - "description": "Cron expression for when the integrity check should run", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "percentageLimit": { - "description": "Percentage limit of the integrity checksum job", - "format": "double", - "maximum": 1, - "minimum": 0, - "type": "number" - }, - "timeLimit": { - "description": "How long the integrity checksum job may run for", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - } - }, - "required": [ - "cronExpression", - "enabled", - "percentageLimit", - "timeLimit" - ], - "type": "object" - }, - "SystemConfigIntegrityJob": { - "description": "Integrity job config", - "properties": { - "cronExpression": { - "description": "Cron expression for when the integrity check should run", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "cronExpression", - "enabled" - ], - "type": "object" - }, - "SystemConfigJobDto": { - "properties": { - "backgroundTask": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "editor": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "faceDetection": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "integrityCheck": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "library": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "metadataExtraction": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "migration": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "notifications": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "ocr": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "search": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "sidecar": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "smartSearch": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "thumbnailGeneration": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "videoConversion": { - "$ref": "#/components/schemas/JobSettingsDto" - }, - "workflow": { - "$ref": "#/components/schemas/JobSettingsDto" - } - }, - "required": [ - "backgroundTask", - "editor", - "faceDetection", - "integrityCheck", - "library", - "metadataExtraction", - "migration", - "notifications", - "ocr", - "search", - "sidecar", - "smartSearch", - "thumbnailGeneration", - "videoConversion", - "workflow" - ], - "type": "object" - }, - "SystemConfigLibraryDto": { - "properties": { - "scan": { - "$ref": "#/components/schemas/SystemConfigLibraryScanDto" - }, - "watch": { - "$ref": "#/components/schemas/SystemConfigLibraryWatchDto" - } - }, - "required": [ - "scan", - "watch" - ], - "type": "object" - }, - "SystemConfigLibraryScanDto": { - "properties": { - "cronExpression": { - "description": "Cron expression", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "cronExpression", - "enabled" - ], - "type": "object" - }, - "SystemConfigLibraryWatchDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "SystemConfigLoggingDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "level": { - "$ref": "#/components/schemas/LogLevel" - } - }, - "required": [ - "enabled", - "level" - ], - "type": "object" - }, - "SystemConfigMachineLearningDto": { - "properties": { - "availabilityChecks": { - "$ref": "#/components/schemas/MachineLearningAvailabilityChecksDto" - }, - "clip": { - "$ref": "#/components/schemas/CLIPConfig" - }, - "duplicateDetection": { - "$ref": "#/components/schemas/DuplicateDetectionConfig" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "facialRecognition": { - "$ref": "#/components/schemas/FacialRecognitionConfig" - }, - "ocr": { - "$ref": "#/components/schemas/OcrConfig" - }, - "urls": { - "description": "ML service URLs", - "items": { - "type": "string" - }, - "minItems": 1, - "type": "array" - } - }, - "required": [ - "availabilityChecks", - "clip", - "duplicateDetection", - "enabled", - "facialRecognition", - "ocr", - "urls" - ], - "type": "object" - }, - "SystemConfigMapDto": { - "properties": { - "darkStyle": { - "description": "Dark map style URL", - "format": "uri", - "type": "string" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "lightStyle": { - "description": "Light map style URL", - "format": "uri", - "type": "string" - } - }, - "required": [ - "darkStyle", - "enabled", - "lightStyle" - ], - "type": "object" - }, - "SystemConfigMetadataDto": { - "properties": { - "faces": { - "$ref": "#/components/schemas/SystemConfigFacesDto" - } - }, - "required": [ - "faces" - ], - "type": "object" - }, - "SystemConfigNewVersionCheckDto": { - "properties": { - "channel": { - "$ref": "#/components/schemas/ReleaseChannel" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "channel", - "enabled" - ], - "type": "object" - }, - "SystemConfigNightlyTasksDto": { - "properties": { - "clusterNewFaces": { - "description": "Cluster new faces", - "type": "boolean" - }, - "databaseCleanup": { - "description": "Database cleanup", - "type": "boolean" - }, - "generateMemories": { - "description": "Generate memories", - "type": "boolean" - }, - "missingThumbnails": { - "description": "Missing thumbnails", - "type": "boolean" - }, - "startTime": { - "description": "Start time (HH:MM)", - "pattern": "^(?:[01]\\d|2[0-3]):[0-5]\\d$", - "type": "string" - }, - "syncQuotaUsage": { - "description": "Sync quota usage", - "type": "boolean" - } - }, - "required": [ - "clusterNewFaces", - "databaseCleanup", - "generateMemories", - "missingThumbnails", - "startTime", - "syncQuotaUsage" - ], - "type": "object" - }, - "SystemConfigNotificationsDto": { - "properties": { - "smtp": { - "$ref": "#/components/schemas/SystemConfigSmtpDto" - } - }, - "required": [ - "smtp" - ], - "type": "object" - }, - "SystemConfigOAuthDto": { - "properties": { - "allowInsecureRequests": { - "description": "Allow insecure requests", - "type": "boolean" - }, - "autoLaunch": { - "description": "Auto launch", - "type": "boolean" - }, - "autoRegister": { - "description": "Auto register", - "type": "boolean" - }, - "buttonText": { - "description": "Button text", - "type": "string" - }, - "clientId": { - "description": "Client ID", - "type": "string" - }, - "clientSecret": { - "description": "Client secret", - "type": "string" - }, - "defaultStorageQuota": { - "description": "Default storage quota", - "maximum": 9007199254740991, - "minimum": 0, - "nullable": true, - "type": "integer" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "endSessionEndpoint": { - "description": "End session endpoint", - "type": "string" - }, - "issuerUrl": { - "description": "Issuer URL", - "type": "string" - }, - "mobileOverrideEnabled": { - "description": "Mobile override enabled", - "type": "boolean" - }, - "mobileRedirectUri": { - "description": "Mobile redirect URI (set to empty string to disable)", - "type": "string" - }, - "profileSigningAlgorithm": { - "description": "Profile signing algorithm", - "type": "string" - }, - "prompt": { - "description": "OAuth prompt parameter (e.g. select_account, login, consent)", - "type": "string" - }, - "roleClaim": { - "description": "Role claim", - "type": "string" - }, - "scope": { - "description": "Scope", - "type": "string" - }, - "signingAlgorithm": { - "description": "Signing algorithm", - "type": "string" - }, - "storageLabelClaim": { - "description": "Storage label claim", - "type": "string" - }, - "storageQuotaClaim": { - "description": "Storage quota claim", - "type": "string" - }, - "timeout": { - "description": "Timeout", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - }, - "tokenEndpointAuthMethod": { - "$ref": "#/components/schemas/OAuthTokenEndpointAuthMethod" - } - }, - "required": [ - "allowInsecureRequests", - "autoLaunch", - "autoRegister", - "buttonText", - "clientId", - "clientSecret", - "defaultStorageQuota", - "enabled", - "endSessionEndpoint", - "issuerUrl", - "mobileOverrideEnabled", - "mobileRedirectUri", - "profileSigningAlgorithm", - "prompt", - "roleClaim", - "scope", - "signingAlgorithm", - "storageLabelClaim", - "storageQuotaClaim", - "timeout", - "tokenEndpointAuthMethod" - ], - "type": "object" - }, - "SystemConfigPasswordLoginDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "SystemConfigReverseGeocodingDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, - "SystemConfigServerDto": { - "properties": { - "externalDomain": { - "description": "External domain", - "type": "string" - }, - "loginPageMessage": { - "description": "Login page message", - "type": "string" - }, - "publicUsers": { - "description": "Public users", - "type": "boolean" - } - }, - "required": [ - "externalDomain", - "loginPageMessage", - "publicUsers" - ], - "type": "object" - }, - "SystemConfigSmtpDto": { - "properties": { - "enabled": { - "description": "Whether SMTP email notifications are enabled", - "type": "boolean" - }, - "from": { - "description": "Email address to send from", - "type": "string" - }, - "replyTo": { - "description": "Email address for replies", - "type": "string" - }, - "transport": { - "$ref": "#/components/schemas/SystemConfigSmtpTransportDto" - } - }, - "required": [ - "enabled", - "from", - "replyTo", - "transport" - ], - "type": "object" - }, - "SystemConfigSmtpTransportDto": { - "properties": { - "host": { - "description": "SMTP server hostname", - "type": "string" - }, - "ignoreCert": { - "description": "Whether to ignore SSL certificate errors", - "type": "boolean" - }, - "password": { - "description": "SMTP password", - "type": "string" - }, - "port": { - "description": "SMTP server port", - "maximum": 65535, - "minimum": 0, - "type": "integer" - }, - "secure": { - "description": "Whether to use secure connection (TLS/SSL)", - "type": "boolean" - }, - "username": { - "description": "SMTP username", - "type": "string" - } - }, - "required": [ - "host", - "ignoreCert", - "password", - "port", - "secure", - "username" - ], - "type": "object" - }, - "SystemConfigStorageTemplateDto": { - "properties": { - "enabled": { - "description": "Enabled", - "type": "boolean" - }, - "hashVerificationEnabled": { - "description": "Hash verification enabled", - "type": "boolean" - }, - "template": { - "description": "Template", - "type": "string" - } - }, - "required": [ - "enabled", - "hashVerificationEnabled", - "template" - ], - "type": "object" - }, - "SystemConfigTemplateEmailsDto": { - "properties": { - "albumInviteTemplate": { - "description": "Album invite template", - "type": "string" - }, - "albumUpdateTemplate": { - "description": "Album update template", - "type": "string" - }, - "welcomeTemplate": { - "description": "Welcome template", - "type": "string" - } - }, - "required": [ - "albumInviteTemplate", - "albumUpdateTemplate", - "welcomeTemplate" - ], - "type": "object" - }, "SystemConfigTemplateStorageOptionDto": { "properties": { "dayOptions": { @@ -26646,62 +28097,6 @@ ], "type": "object" }, - "SystemConfigTemplatesDto": { - "properties": { - "email": { - "$ref": "#/components/schemas/SystemConfigTemplateEmailsDto" - } - }, - "required": [ - "email" - ], - "type": "object" - }, - "SystemConfigThemeDto": { - "properties": { - "customCss": { - "description": "Custom CSS for theming", - "type": "string" - } - }, - "required": [ - "customCss" - ], - "type": "object" - }, - "SystemConfigTrashDto": { - "properties": { - "days": { - "description": "Days", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "enabled": { - "description": "Enabled", - "type": "boolean" - } - }, - "required": [ - "days", - "enabled" - ], - "type": "object" - }, - "SystemConfigUserDto": { - "properties": { - "deleteDelay": { - "description": "Delete delay", - "maximum": 9007199254740991, - "minimum": 1, - "type": "integer" - } - }, - "required": [ - "deleteDelay" - ], - "type": "object" - }, "TagBulkAssetsDto": { "properties": { "assetIds": { @@ -27358,7 +28753,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "isAdmin": { @@ -27422,6 +28817,18 @@ "avatarColor": { "$ref": "#/components/schemas/UserAvatarColor" }, + "clusterGroupId": { + "description": "Cluster group the user is a member of", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + } + ] + }, "createdAt": { "description": "Creation date", "example": "2024-01-01T00:00:00.000Z", @@ -27440,7 +28847,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "id": { @@ -27514,6 +28921,7 @@ }, "required": [ "avatarColor", + "clusterGroupId", "createdAt", "deletedAt", "email", @@ -27546,7 +28954,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "isAdmin": { @@ -27603,6 +29011,362 @@ ], "type": "string" }, + "UserConfigClipDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigDto": { + "description": "Configuration properties that are visible to a logged user", + "properties": { + "ffmpeg": { + "$ref": "#/components/schemas/UserConfigFFmpegDto" + }, + "image": { + "$ref": "#/components/schemas/UserConfigImageDto" + }, + "machineLearning": { + "$ref": "#/components/schemas/UserConfigMachineLearningDto" + }, + "map": { + "$ref": "#/components/schemas/UserConfigMapDto" + }, + "oauth": { + "$ref": "#/components/schemas/UserConfigOAuthDto" + }, + "passwordLogin": { + "$ref": "#/components/schemas/UserConfigPasswordLoginDto" + }, + "reverseGeocoding": { + "$ref": "#/components/schemas/UserConfigReverseGeocodingDto" + }, + "server": { + "$ref": "#/components/schemas/UserConfigServerDto" + }, + "theme": { + "$ref": "#/components/schemas/UserConfigThemeDto" + }, + "trash": { + "$ref": "#/components/schemas/UserConfigTrashDto" + }, + "user": { + "$ref": "#/components/schemas/UserConfigUserDto" + } + }, + "required": [ + "ffmpeg", + "image", + "machineLearning", + "map", + "oauth", + "passwordLogin", + "reverseGeocoding", + "server", + "theme", + "trash", + "user" + ], + "type": "object" + }, + "UserConfigDuplicateDetectionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigFFmpegDto": { + "properties": { + "realtime": { + "$ref": "#/components/schemas/UserConfigFFmpegRealtimeDto" + } + }, + "required": [ + "realtime" + ], + "type": "object" + }, + "UserConfigFFmpegRealtimeDto": { + "properties": { + "enabled": { + "description": "Enable real-time HLS transcoding (alpha)", + "type": "boolean" + }, + "resolutions": { + "description": "Resolutions to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/HlsVideoResolution" + }, + "type": "array" + }, + "videoCodecs": { + "description": "Video codecs to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/VideoCodec" + }, + "type": "array" + } + }, + "required": [ + "enabled", + "resolutions", + "videoCodecs" + ], + "type": "object" + }, + "UserConfigFacialRecognitionDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + }, + "minFaces": { + "description": "Minimum number of faces required for recognition", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "enabled", + "minFaces" + ], + "type": "object" + }, + "UserConfigGeneratedFullsizeImageDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigGeneratedImageDto": { + "properties": { + "size": { + "description": "Size", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "size" + ], + "type": "object" + }, + "UserConfigImageDto": { + "properties": { + "fullsize": { + "$ref": "#/components/schemas/UserConfigGeneratedFullsizeImageDto" + }, + "preview": { + "$ref": "#/components/schemas/UserConfigGeneratedImageDto" + }, + "thumbnail": { + "$ref": "#/components/schemas/UserConfigGeneratedImageDto" + } + }, + "required": [ + "fullsize", + "preview", + "thumbnail" + ], + "type": "object" + }, + "UserConfigMachineLearningDto": { + "properties": { + "clip": { + "$ref": "#/components/schemas/UserConfigClipDto" + }, + "duplicateDetection": { + "$ref": "#/components/schemas/UserConfigDuplicateDetectionDto" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "facialRecognition": { + "$ref": "#/components/schemas/UserConfigFacialRecognitionDto" + }, + "ocr": { + "$ref": "#/components/schemas/UserConfigOcrDto" + } + }, + "required": [ + "clip", + "duplicateDetection", + "enabled", + "facialRecognition", + "ocr" + ], + "type": "object" + }, + "UserConfigMapDto": { + "properties": { + "darkStyle": { + "description": "Dark map style URL", + "format": "uri", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + }, + "lightStyle": { + "description": "Light map style URL", + "format": "uri", + "type": "string" + } + }, + "required": [ + "darkStyle", + "enabled", + "lightStyle" + ], + "type": "object" + }, + "UserConfigOAuthDto": { + "properties": { + "autoLaunch": { + "description": "Auto launch", + "type": "boolean" + }, + "buttonText": { + "description": "Button text", + "type": "string" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "autoLaunch", + "buttonText", + "enabled" + ], + "type": "object" + }, + "UserConfigOcrDto": { + "properties": { + "enabled": { + "description": "Whether the task is enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigPasswordLoginDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigReverseGeocodingDto": { + "properties": { + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, + "UserConfigServerDto": { + "properties": { + "externalDomain": { + "description": "External domain", + "type": "string" + }, + "loginPageMessage": { + "description": "Login page message", + "type": "string" + }, + "publicUsers": { + "description": "Public users", + "type": "boolean" + } + }, + "required": [ + "externalDomain", + "loginPageMessage", + "publicUsers" + ], + "type": "object" + }, + "UserConfigThemeDto": { + "properties": { + "customCss": { + "description": "Custom CSS for theming", + "type": "string" + } + }, + "required": [ + "customCss" + ], + "type": "object" + }, + "UserConfigTrashDto": { + "properties": { + "days": { + "description": "Days", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "enabled": { + "description": "Enabled", + "type": "boolean" + } + }, + "required": [ + "days", + "enabled" + ], + "type": "object" + }, + "UserConfigUserDto": { + "properties": { + "deleteDelay": { + "description": "Delete delay", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "deleteDelay" + ], + "type": "object" + }, "UserLicense": { "properties": { "activatedAt": { @@ -27745,7 +29509,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "id": { @@ -27800,7 +29564,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\p{L}\\p{M}\\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?(?:\\.[\\p{L}\\p{N}](?:[\\p{L}\\p{M}\\p{N}-]{0,61}[\\p{L}\\p{M}\\p{N}])?)*$", "type": "string" }, "name": { @@ -27931,6 +29695,10 @@ "description": "Workflow enabled", "type": "boolean" }, + "logging": { + "description": "Workflow logs run results", + "type": "boolean" + }, "name": { "description": "Workflow name", "nullable": true, @@ -27952,6 +29720,59 @@ ], "type": "object" }, + "WorkflowLogEntryDto": { + "properties": { + "at": { + "description": "Workflow run date/time", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "id": { + "description": "Workflow log entry ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "lastStep": { + "description": "Last step ran, if the workflow ended early", + "properties": { + "index": { + "description": "Index of the step in the workflow", + "exclusiveMinimum": true, + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "method": { + "description": "Method of the step", + "type": "string" + } + }, + "required": [ + "method", + "index" + ], + "type": "object" + }, + "result": { + "$ref": "#/components/schemas/WorkflowResult" + }, + "triggerDataId": { + "description": "Workflow trigger data ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "at", + "id", + "result" + ], + "type": "object" + }, "WorkflowResponseDto": { "properties": { "createdAt": { @@ -27973,6 +29794,10 @@ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", "type": "string" }, + "logging": { + "description": "Workflow logs run results", + "type": "boolean" + }, "name": { "description": "Workflow name", "nullable": true, @@ -27999,6 +29824,7 @@ "description", "enabled", "id", + "logging", "name", "steps", "trigger", @@ -28006,6 +29832,15 @@ ], "type": "object" }, + "WorkflowResult": { + "description": "Workflow run result", + "enum": [ + "completed", + "halted", + "error" + ], + "type": "string" + }, "WorkflowShareResponseDto": { "properties": { "description": { @@ -28088,7 +29923,8 @@ "description": "Plugin trigger type", "enum": [ "AssetCreate", - "AssetMetadataExtraction" + "AssetMetadataExtraction", + "AssetTagged" ], "type": "string" }, @@ -28130,6 +29966,10 @@ "description": "Workflow enabled", "type": "boolean" }, + "logging": { + "description": "Workflow logs run results", + "type": "boolean" + }, "name": { "description": "Workflow name", "nullable": true, diff --git a/open-api/patch/pubspec_immich_mobile.yaml.patch b/open-api/patch/pubspec_immich_mobile.yaml.patch index ad3bbc2edd..be210f864f 100644 --- a/open-api/patch/pubspec_immich_mobile.yaml.patch +++ b/open-api/patch/pubspec_immich_mobile.yaml.patch @@ -6,4 +6,4 @@ -dev_dependencies: - test: '>=1.21.6 <1.22.0' + immich_mobile: -+ path: ../ ++ path: ../../ diff --git a/package.json b/package.json index 75222c8220..0ff7f536fb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "format": "prettier --cache --check i18n/", "format:fix": "prettier --cache --write --list-different i18n" }, - "packageManager": "pnpm@11.20.0", + "packageManager": "pnpm@11.21.0", "engines": { "pnpm": ">=10.0.0" }, diff --git a/packages/cli/bin/immich b/packages/cli/bin/immich index 924fff1230..280b3c021b 100755 --- a/packages/cli/bin/immich +++ b/packages/cli/bin/immich @@ -1,2 +1,12 @@ #!/usr/bin/env node -import '../dist/index.js'; +import { createRequire } from 'node:module'; +import semver from 'semver'; + +const { engines } = createRequire(import.meta.url)('../package.json'); + +if (!semver.satisfies(process.version, engines.node, { includePrerelease: true })) { + console.error(`The Immich CLI requires Node.js ${engines.node}, but you are running ${process.version}.`); + process.exit(1); +} + +await import('../dist/index.js'); diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index bcc74c654d..d6f32a9abc 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, @@ -47,6 +42,7 @@ export default typescriptEslint.config([ 'unicorn/import-style': 'off', 'unicorn/consistent-class-member-order': 'off', 'unicorn/prefer-simple-condition-first': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], curly: 2, // prefer the typescript-eslint type-aware version 'unicorn/require-array-sort-compare': 'off', diff --git a/packages/cli/package.json b/packages/cli/package.json index 9540cefd4c..2a0f0a76bf 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,7 +33,7 @@ "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^72.0.0", + "eslint-plugin-unicorn": "^73.0.0", "globals": "^17.0.0", "mock-fs": "^5.2.0", "prettier": "^3.7.4", @@ -59,13 +59,14 @@ "check": "tsc --noEmit" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "dependencies": { "chokidar": "^4.0.3", "fast-glob": "^3.3.2", "fastq": "^1.17.1", "lodash-es": "^4.17.21", - "micromatch": "^4.0.8" + "micromatch": "^4.0.8", + "semver": "^7.8.1" } } diff --git a/packages/e2e-auth-server/Dockerfile b/packages/e2e-auth-server/Dockerfile index 087fa45d23..183b4ef096 100644 --- a/packages/e2e-auth-server/Dockerfile +++ b/packages/e2e-auth-server/Dockerfile @@ -1,9 +1,15 @@ FROM node:24.19.0-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d WORKDIR /usr/src/app -COPY package* pnpm* .pnpmfile.cjs ./ -COPY ./packages ./packages/ -WORKDIR /usr/src/app/packages/e2e-auth-server + RUN corepack enable + +COPY package* pnpm* .pnpmfile.cjs ./ +COPY ./packages/e2e-auth-server/package.json ./packages/e2e-auth-server/ + +WORKDIR /usr/src/app/packages/e2e-auth-server RUN pnpm install --frozen-lockfile + +COPY ./packages/e2e-auth-server ./ + EXPOSE 2286 CMD ["pnpm", "run", "start"] diff --git a/packages/e2e-auth-server/package.json b/packages/e2e-auth-server/package.json index eae4fd3e04..d92f62d981 100644 --- a/packages/e2e-auth-server/package.json +++ b/packages/e2e-auth-server/package.json @@ -13,5 +13,5 @@ "oidc-provider": "^9.0.0", "tsx": "^4.20.6" }, - "packageManager": "pnpm@11.20.0" + "packageManager": "pnpm@11.21.0" } diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index a72d60c04d..d7f7830a0e 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -308,6 +308,56 @@ }, "uiHints": ["Filter"] }, + { + "name": "assetAddTags", + "title": "Add Tags", + "description": "Add tags to an asset", + "types": ["AssetV1"], + "hostFunctions": true, + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Tags to add to the asset", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + } + }, + "required": ["tags"] + } + }, + { + "name": "assetTagFilter", + "title": "Filter by tags", + "description": "Filter assets by which tags they have", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Which tags the asset must have", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + }, + "matching": { + "title": "Matching", + "description": "Whether assets must have all, any, or none of the listed tags", + "type": "string", + "enum": ["all", "any", "none"] + } + }, + "required": ["tags", "matching"] + }, + "uiHints": ["Filter"] + }, { "name": "assetExifFilter", "title": "Filter by EXIF metadata", diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 7b91ed7111..251393ace5 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -39,6 +39,11 @@ const matchValueResult = (value: string, config: MatchValueConfig) => { }; const methods = wrapper({ + assetAddTags: ({ config, data, functions }) => { + functions.bulkTagAssets({ assetIds: [data.asset.id], tagIds: config.tags }); + return {}; + }, + assetAddToAlbums: ({ config, data, functions }) => { const assetId = data.asset.id; @@ -176,6 +181,24 @@ const methods = wrapper({ return { workflow: { continue: hasTimeZone === needsTimeZone } }; }, + assetTagFilter: ({ config, data }) => { + const assetTags = data.asset.tags.map((tag) => tag.id); + + for (const tag of config.tags) { + if (assetTags.includes(tag)) { + if (config.matching === 'any') { + break; + } else if (config.matching === 'none') { + return { workflow: { continue: false } }; + } + } else if (config.matching === 'all') { + return { workflow: { continue: false } }; + } + } + + return { workflow: { continue: true } }; + }, + assetTypeFilter: ({ config, data }) => { return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; }, @@ -208,6 +231,7 @@ const methods = wrapper({ }); const { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -217,6 +241,7 @@ const { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, @@ -226,6 +251,7 @@ const { } = methods; export { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -235,6 +261,7 @@ export { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 4ae3ca76a7..eee0ae1088 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -27,7 +27,7 @@ "keywords": [], "author": "", "license": "GNU Affero General Public License version 3", - "packageManager": "pnpm@11.20.0", + "packageManager": "pnpm@11.21.0", "devDependencies": { "@extism/js-pdk": "^1.1.1", "@immich/sdk": "workspace:*", diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 9d52630438..e0b6e34da9 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -4,6 +4,8 @@ import { type BulkIdResponseDto, type BulkIdsDto, type CreateAlbumDto, + type TagBulkAssetsDto, + type TagBulkAssetsResponseDto, } from '@immich/sdk'; declare module 'extism:host' { @@ -47,6 +49,7 @@ export const availableFunctions = [ 'addAssetsToAlbum', 'addAssetsToAlbums', 'httpRequest', + 'bulkTagAssets', ] as const; export const hostFunctions = (authToken: string) => { @@ -96,5 +99,11 @@ export const hostFunctions = (authToken: string) => { authToken, [url, options], ), + bulkTagAssets: (dto: TagBulkAssetsDto) => + call<[TagBulkAssetsDto], TagBulkAssetsResponseDto>( + 'bulkTagAssets', + authToken, + [dto], + ), } satisfies Record<(typeof availableFunctions)[number], unknown>; }; diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 9756d652c3..94c968978d 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -1,4 +1,9 @@ -import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; +import type { + AssetTypeEnum, + AssetVisibility, + TagResponseDto, + WorkflowType, +} from '@immich/sdk'; type DeepPartial = T extends Date ? T @@ -18,6 +23,7 @@ export type WorkflowEventData = WorkflowEventMap[T]; export enum WorkflowTrigger { AssetCreate = 'AssetCreate', AssetMetadataExtraction = 'AssetMetadataExtraction', + AssetTagged = 'AssetTagged', // PersonRecognized = 'PersonRecognized', } @@ -88,6 +94,7 @@ export type AssetV1 = { duplicateId: string | null; visibility: AssetVisibility; isEdited: boolean; + tags: TagResponseDto[]; exifInfo: { make: string | null; model: string | null; diff --git a/packages/scripts/package.json b/packages/scripts/package.json index ca08f10759..69841e3bc9 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -32,5 +32,5 @@ "vite": "^8.0.16", "vitest": "^4.1.8" }, - "packageManager": "pnpm@11.20.0" + "packageManager": "pnpm@11.21.0" } diff --git a/packages/scripts/src/commands/release.spec.ts b/packages/scripts/src/commands/release.spec.ts index 0b1be22302..dc3f82517d 100644 --- a/packages/scripts/src/commands/release.spec.ts +++ b/packages/scripts/src/commands/release.spec.ts @@ -1,23 +1,10 @@ import semver from 'semver'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - resolveArchivedVersions, - ArchivedVersion, - getMobileBuild, - getNewVersion, - ReleaseError, -} from './release'; +import { describe, expect, it } from 'vitest'; +import { getMobileBuild, getNewVersion, ReleaseError } from './release'; const mobileBuild = (version: string) => getMobileBuild(semver.parse(version) as semver.SemVer); -const archived = (label: string): ArchivedVersion => ({ - label, - url: `https://docs.${label}.archive.immich.app`, -}); - -const labels = (versions: ArchivedVersion[]) => versions.map((v) => v.label); - describe(getNewVersion.name, () => { describe('transitions', () => { const valid = [ @@ -82,67 +69,6 @@ describe(getNewVersion.name, () => { }); }); -describe(resolveArchivedVersions.name, () => { - beforeEach(() => { - // silence the "Removed ..." progress logging - vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - it('should prepend the new version to the front', () => { - const result = resolveArchivedVersions([archived('v2.9.0')], '3.0.0'); - - expect(result[0]).toEqual({ - label: 'v3.0.0', - url: 'https://docs.v3.0.0.archive.immich.app', - }); - expect(labels(result)).toEqual(['v3.0.0', 'v2.9.0']); - }); - - it('should handle an empty list', () => { - expect(labels(resolveArchivedVersions([], '3.0.0'))).toEqual(['v3.0.0']); - }); - - it('should drop older patch releases of the new version minor', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v3.0.2'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - ]); - }); - - it('should keep the latest patch of each older minor', () => { - const versions = ['v3.1.2', 'v3.1.1', 'v3.0.9', 'v2.0.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.2.0'))).toEqual([ - 'v3.2.0', - 'v3.1.2', - 'v3.0.9', - 'v2.0.0', - ]); - }); - - it('should keep an older minor when bumping a patch', () => { - const versions = ['v3.0.4', 'v3.0.3', 'v2.9.1', 'v2.9.0', 'v1.5.0'].map( - archived, - ); - - expect(labels(resolveArchivedVersions(versions, '3.0.5'))).toEqual([ - 'v3.0.5', - 'v2.9.1', - 'v1.5.0', - ]); - }); - - it('should replace a prerelease with its release', () => { - const versions = ['v3.0.0-rc.2', 'v3.0.0-rc.1', 'v2.9.0'].map(archived); - - expect(labels(resolveArchivedVersions(versions, '3.0.0'))).toEqual([ - 'v3.0.0', - 'v2.9.0', - ]); - }); -}); - describe(getMobileBuild.name, () => { it('should encode the version', () => { expect(mobileBuild('3.1.0')).toBe(3_010_099); diff --git a/packages/scripts/src/commands/release.ts b/packages/scripts/src/commands/release.ts index 307b66165a..2c0d82d5f6 100644 --- a/packages/scripts/src/commands/release.ts +++ b/packages/scripts/src/commands/release.ts @@ -26,7 +26,6 @@ const Files = { Docs: { Env: join(root, 'docs/docs/install/environment-variables.md'), Upgrading: join(root, 'docs/docs/install/upgrading.md'), - ArchivedVersions: join(root, 'docs/static/archived-versions.json'), }, Mobile: { Pubspec: join(root, 'mobile/pubspec.yaml'), @@ -92,13 +91,6 @@ export const handleRelease = ({ type }: ReleaseOptions) => { pump(Files.Docs.Upgrading, /:v\d+/, `:${major}`); } - // update archived versions list - const archivedFile = new JsonFile( - Files.Docs.ArchivedVersions, - ); - const versions = archivedFile.read(); - archivedFile.write(resolveArchivedVersions(versions, newVersionRaw)); - if (process.env.GITHUB_ENV) { // make available for following steps appendFileSync( @@ -222,50 +214,6 @@ const pump = (path: string, pattern: RegExp, replacement: string) => { file.write(update); }; -export interface ArchivedVersion { - label: string; - url: string; -} - -export const resolveArchivedVersions = ( - versions: ArchivedVersion[], - nextVersion: string, -): ArchivedVersion[] => { - const newVersion: ArchivedVersion = { - label: `v${nextVersion}`, - url: `https://docs.v${nextVersion}.archive.immich.app`, - }; - - let result = versions; - let lastVersion = asVersion(newVersion); - for (const item of versions) { - const version = asVersion(item); - // only keep the latest patch version for each minor release - if ( - lastVersion.major === version.major && - lastVersion.minor === version.minor && - lastVersion.patch >= version.patch - ) { - result = result.filter((item) => item.label !== version.label); - console.log( - `Removed ${version.label} (replaced with ${lastVersion.label})`, - ); - continue; - } - - lastVersion = version; - } - - return [newVersion, ...result]; -}; - -const asVersion = (item: ArchivedVersion) => { - const { label, url } = item; - const [version] = label.substring(1).split('-'); - const [major, minor, patch] = version.split('.').map(Number); - return { major, minor, patch, label, url }; -}; - const isPrerelease = (version: SemVer) => version.prerelease.length > 0; /** diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index d159ced5bd..b7620d3082 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -54,6 +54,382 @@ export type ActivityStatisticsResponseDto = { /** Number of likes */ likes: number; }; +export type AdminConfigDatabaseBackupDto = { + /** Cron expression */ + cronExpression: string; + /** Enabled */ + enabled: boolean; + /** Keep last amount */ + keepLastAmount: number; +}; +export type AdminConfigBackupsDto = { + database: AdminConfigDatabaseBackupDto; +}; +export type AdminConfigFFmpegRealtimeDto = { + /** Enable real-time HLS transcoding (alpha) */ + enabled: boolean; + /** Resolutions to use for real-time HLS transcoding */ + resolutions: HlsVideoResolution[]; + /** Video codecs to use for real-time HLS transcoding */ + videoCodecs: VideoCodec[]; +}; +export type AdminConfigFFmpegDto = { + accel: TranscodeHWAccel; + /** Accelerated decode */ + accelDecode: boolean; + /** Accepted audio codecs */ + acceptedAudioCodecs: AudioCodec[]; + /** Accepted containers */ + acceptedContainers: VideoContainer[]; + /** Accepted video codecs */ + acceptedVideoCodecs: VideoCodec[]; + /** B-frames */ + bframes: number; + cqMode: CQMode; + /** CRF */ + crf: number; + /** GOP size */ + gopSize: number; + /** Max bitrate */ + maxBitrate: string; + /** Preferred hardware device */ + preferredHwDevice: string; + /** Preset */ + preset: string; + realtime: AdminConfigFFmpegRealtimeDto; + /** References */ + refs: number; + targetAudioCodec: AudioCodec; + /** Target resolution */ + targetResolution: string; + targetVideoCodec: VideoCodec; + /** Temporal AQ */ + temporalAQ: boolean; + /** Threads */ + threads: number; + tonemap: ToneMapping; + transcode: TranscodePolicy; + /** Two pass */ + twoPass: boolean; +}; +export type AdminConfigGeneratedFullsizeImageDto = { + /** Enabled */ + enabled: boolean; + format: ImageFormat; + /** Progressive */ + progressive?: boolean; + /** Quality */ + quality: number; +}; +export type AdminConfigGeneratedImageDto = { + format: ImageFormat; + /** Progressive */ + progressive?: boolean; + /** Quality */ + quality: number; + /** Size */ + size: number; +}; +export type AdminConfigImageDto = { + colorspace: Colorspace; + /** Extract embedded */ + extractEmbedded: boolean; + fullsize: AdminConfigGeneratedFullsizeImageDto; + preview: AdminConfigGeneratedImageDto; + thumbnail: AdminConfigGeneratedImageDto; +}; +export type AdminConfigIntegrityChecksumJobDto = { + /** Cron expression for when the integrity check should run */ + cronExpression: string; + /** Enabled */ + enabled: boolean; + /** Percentage limit of the integrity checksum job */ + percentageLimit: number; + /** How long the integrity checksum job may run for */ + timeLimit: number; +}; +export type AdminConfigIntegrityJobDto = { + /** Cron expression for when the integrity check should run */ + cronExpression: string; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigIntegrityChecksDto = { + checksumFiles: AdminConfigIntegrityChecksumJobDto; + missingFiles: AdminConfigIntegrityJobDto; + untrackedFiles: AdminConfigIntegrityJobDto; +}; +export type AdminConfigJobSettingsDto = { + /** Concurrency */ + concurrency: number; +}; +export type AdminConfigJobDto = { + backgroundTask: AdminConfigJobSettingsDto; + editor: AdminConfigJobSettingsDto; + faceDetection: AdminConfigJobSettingsDto; + integrityCheck: AdminConfigJobSettingsDto; + library: AdminConfigJobSettingsDto; + metadataExtraction: AdminConfigJobSettingsDto; + migration: AdminConfigJobSettingsDto; + notifications: AdminConfigJobSettingsDto; + ocr: AdminConfigJobSettingsDto; + search: AdminConfigJobSettingsDto; + sidecar: AdminConfigJobSettingsDto; + smartSearch: AdminConfigJobSettingsDto; + thumbnailGeneration: AdminConfigJobSettingsDto; + videoConversion: AdminConfigJobSettingsDto; + workflow: AdminConfigJobSettingsDto; +}; +export type AdminConfigLibraryScanDto = { + /** Cron expression */ + cronExpression: string; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigLibraryWatchDto = { + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigLibraryDto = { + scan: AdminConfigLibraryScanDto; + watch: AdminConfigLibraryWatchDto; +}; +export type AdminConfigLoggingDto = { + /** Enabled */ + enabled: boolean; + level: LogLevel; +}; +export type AdminConfigMachineLearningAvailabilityChecksDto = { + /** Enabled */ + enabled: boolean; + interval: number; + timeout: number; +}; +export type AdminConfigClipDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Name of the model to use */ + modelName: string; +}; +export type AdminConfigDuplicateDetectionDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Maximum distance threshold for duplicate detection */ + maxDistance: number; +}; +export type AdminConfigFacialRecognitionDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Maximum distance threshold for face recognition */ + maxDistance: number; + /** Minimum number of faces required for recognition */ + minFaces: number; + /** Minimum confidence score for face detection */ + minScore: number; + /** Name of the model to use */ + modelName: string; +}; +export type AdminConfigOcrDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Maximum resolution for OCR processing */ + maxResolution: number; + /** Minimum confidence score for text detection */ + minDetectionScore: number; + /** Minimum confidence score for text recognition */ + minRecognitionScore: number; + /** Name of the model to use */ + modelName: string; +}; +export type AdminConfigMachineLearningDto = { + availabilityChecks: AdminConfigMachineLearningAvailabilityChecksDto; + clip: AdminConfigClipDto; + duplicateDetection: AdminConfigDuplicateDetectionDto; + /** Enabled */ + enabled: boolean; + facialRecognition: AdminConfigFacialRecognitionDto; + ocr: AdminConfigOcrDto; + /** ML service URLs */ + urls: string[]; +}; +export type AdminConfigMapDto = { + /** Dark map style URL */ + darkStyle: string; + /** Enabled */ + enabled: boolean; + /** Light map style URL */ + lightStyle: string; +}; +export type AdminConfigFacesDto = { + /** Import */ + "import": boolean; +}; +export type AdminConfigMetadataDto = { + faces: AdminConfigFacesDto; +}; +export type AdminConfigNewVersionCheckDto = { + channel: ReleaseChannel; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigNightlyTasksDto = { + /** Cluster new faces */ + clusterNewFaces: boolean; + /** Database cleanup */ + databaseCleanup: boolean; + /** Generate memories */ + generateMemories: boolean; + /** Missing thumbnails */ + missingThumbnails: boolean; + /** Start time (HH:MM) */ + startTime: string; + /** Sync quota usage */ + syncQuotaUsage: boolean; +}; +export type AdminConfigSmtpTransportDto = { + /** SMTP server hostname */ + host: string; + /** Whether to ignore SSL certificate errors */ + ignoreCert: boolean; + /** SMTP password */ + password: string; + /** SMTP server port */ + port: number; + /** Whether to use secure connection (TLS/SSL) */ + secure: boolean; + /** SMTP username */ + username: string; +}; +export type AdminConfigSmtpDto = { + /** Whether SMTP email notifications are enabled */ + enabled: boolean; + /** Email address to send from */ + "from": string; + /** Email address for replies */ + replyTo: string; + transport: AdminConfigSmtpTransportDto; +}; +export type AdminConfigNotificationsDto = { + smtp: AdminConfigSmtpDto; +}; +export type AdminConfigOAuthDto = { + /** Account management URL */ + accountManagementUrl?: string; + /** Allow insecure requests */ + allowInsecureRequests: boolean; + /** Auto launch */ + autoLaunch: boolean; + /** Auto register */ + autoRegister: boolean; + /** Button text */ + buttonText: string; + /** Client ID */ + clientId: string; + /** Client secret */ + clientSecret: string; + /** Default storage quota */ + defaultStorageQuota: number | null; + /** Enabled */ + enabled: boolean; + /** End session endpoint */ + endSessionEndpoint: string; + /** Issuer URL */ + issuerUrl: string; + /** Mobile override enabled */ + mobileOverrideEnabled: boolean; + /** Mobile redirect URI (set to empty string to disable) */ + mobileRedirectUri: string; + /** Profile signing algorithm */ + profileSigningAlgorithm: string; + /** OAuth prompt parameter (e.g. select_account, login, consent) */ + prompt: string; + /** Role claim */ + roleClaim: string; + /** Scope */ + scope: string; + /** Signing algorithm */ + signingAlgorithm: string; + /** Storage label claim */ + storageLabelClaim: string; + /** Storage quota claim */ + storageQuotaClaim: string; + /** Timeout */ + timeout: number; + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; +}; +export type AdminConfigPasswordLoginDto = { + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigReverseGeocodingDto = { + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigServerDto = { + /** External domain */ + externalDomain: string; + /** Login page message */ + loginPageMessage: string; + /** Public users */ + publicUsers: boolean; +}; +export type AdminConfigStorageTemplateDto = { + /** Enabled */ + enabled: boolean; + /** Hash verification enabled */ + hashVerificationEnabled: boolean; + /** Template */ + template: string; +}; +export type AdminConfigTemplateEmailsDto = { + /** Album invite template */ + albumInviteTemplate: string; + /** Album update template */ + albumUpdateTemplate: string; + /** Welcome template */ + welcomeTemplate: string; +}; +export type AdminConfigTemplatesDto = { + email: AdminConfigTemplateEmailsDto; +}; +export type AdminConfigThemeDto = { + /** Custom CSS for theming */ + customCss: string; +}; +export type AdminConfigTrashDto = { + /** Days */ + days: number; + /** Enabled */ + enabled: boolean; +}; +export type AdminConfigUserDto = { + /** Delete delay */ + deleteDelay: number; +}; +export type AdminConfigDto = { + backup: AdminConfigBackupsDto; + ffmpeg: AdminConfigFFmpegDto; + image: AdminConfigImageDto; + integrityChecks: AdminConfigIntegrityChecksDto; + job: AdminConfigJobDto; + library: AdminConfigLibraryDto; + logging: AdminConfigLoggingDto; + machineLearning: AdminConfigMachineLearningDto; + map: AdminConfigMapDto; + metadata: AdminConfigMetadataDto; + newVersionCheck: AdminConfigNewVersionCheckDto; + nightlyTasks: AdminConfigNightlyTasksDto; + notifications: AdminConfigNotificationsDto; + oauth: AdminConfigOAuthDto; + passwordLogin: AdminConfigPasswordLoginDto; + reverseGeocoding: AdminConfigReverseGeocodingDto; + server: AdminConfigServerDto; + storageTemplate: AdminConfigStorageTemplateDto; + templates: AdminConfigTemplatesDto; + theme: AdminConfigThemeDto; + trash: AdminConfigTrashDto; + user: AdminConfigUserDto; +}; export type DatabaseBackupDeleteDto = { /** Backup filenames to delete */ backups: string[]; @@ -165,29 +541,6 @@ export type TemplateResponseDto = { /** Template name */ name: string; }; -export type SystemConfigSmtpTransportDto = { - /** SMTP server hostname */ - host: string; - /** Whether to ignore SSL certificate errors */ - ignoreCert: boolean; - /** SMTP password */ - password: string; - /** SMTP server port */ - port: number; - /** Whether to use secure connection (TLS/SSL) */ - secure: boolean; - /** SMTP username */ - username: string; -}; -export type SystemConfigSmtpDto = { - /** Whether SMTP email notifications are enabled */ - enabled: boolean; - /** Email address to send from */ - "from": string; - /** Email address for replies */ - replyTo: string; - transport: SystemConfigSmtpTransportDto; -}; export type TestEmailResponseDto = { /** Email message ID */ messageId: string; @@ -202,6 +555,8 @@ export type UserLicense = { }; export type UserAdminResponseDto = { avatarColor: UserAvatarColor; + /** Cluster group the user is a member of */ + clusterGroupId: string; /** Creation date */ createdAt: string; /** Deletion date */ @@ -631,8 +986,18 @@ export type ApiKeyCreateDto = { }; export type ApiKeyCreateResponseDto = { apiKey: ApiKeyResponseDto; + /** Creation date */ + createdAt: string; + /** API key ID */ + id: string; + /** API key name */ + name: string; + /** List of permissions */ + permissions: Permission[]; /** API key secret (only shown once) */ secret: string; + /** Last update date */ + updatedAt: string; }; export type ApiKeyUpdateDto = { /** API key name */ @@ -640,6 +1005,23 @@ export type ApiKeyUpdateDto = { /** List of permissions */ permissions?: Permission[]; }; +export type AssetFileResponseDto = { + /** Creation date */ + createdAt: string; + /** Asset file ID */ + id: string; + /** The file was generated from an edit */ + isEdited: boolean; + /** The file is a progressively encoded JPEG */ + isProgressive: boolean; + /** The file is transparent */ + isTransparent: boolean; + /** File path */ + path: string; + "type": AssetFileType; + /** Update date */ + updatedAt: string; +}; export type AssetBulkDeleteDto = { /** Force delete even if in use */ force?: boolean; @@ -1113,7 +1495,132 @@ export type ValidateAccessTokenResponseDto = { /** Authentication status */ authStatus: boolean; }; +export type ClusterGroupRequestResponseDto = { + /** Cluster group the user is invited to join */ + clusterGroupId: string; + /** Creation date */ + createdAt: string; + /** Request ID */ + id: string; + /** User the request was created for */ + userId: string; +}; +export type ClusterGroupRequestCreateDto = { + /** User to invite into the cluster group */ + userId: string; +}; +export type UserConfigFFmpegRealtimeDto = { + /** Enable real-time HLS transcoding (alpha) */ + enabled: boolean; + /** Resolutions to use for real-time HLS transcoding */ + resolutions: HlsVideoResolution[]; + /** Video codecs to use for real-time HLS transcoding */ + videoCodecs: VideoCodec[]; +}; +export type UserConfigFFmpegDto = { + realtime: UserConfigFFmpegRealtimeDto; +}; +export type UserConfigGeneratedFullsizeImageDto = { + /** Enabled */ + enabled: boolean; +}; +export type UserConfigGeneratedImageDto = { + /** Size */ + size: number; +}; +export type UserConfigImageDto = { + fullsize: UserConfigGeneratedFullsizeImageDto; + preview: UserConfigGeneratedImageDto; + thumbnail: UserConfigGeneratedImageDto; +}; +export type UserConfigClipDto = { + /** Whether the task is enabled */ + enabled: boolean; +}; +export type UserConfigDuplicateDetectionDto = { + /** Whether the task is enabled */ + enabled: boolean; +}; +export type UserConfigFacialRecognitionDto = { + /** Whether the task is enabled */ + enabled: boolean; + /** Minimum number of faces required for recognition */ + minFaces: number; +}; +export type UserConfigOcrDto = { + /** Whether the task is enabled */ + enabled: boolean; +}; +export type UserConfigMachineLearningDto = { + clip: UserConfigClipDto; + duplicateDetection: UserConfigDuplicateDetectionDto; + /** Enabled */ + enabled: boolean; + facialRecognition: UserConfigFacialRecognitionDto; + ocr: UserConfigOcrDto; +}; +export type UserConfigMapDto = { + /** Dark map style URL */ + darkStyle: string; + /** Enabled */ + enabled: boolean; + /** Light map style URL */ + lightStyle: string; +}; +export type UserConfigOAuthDto = { + /** Auto launch */ + autoLaunch: boolean; + /** Button text */ + buttonText: string; + /** Enabled */ + enabled: boolean; +}; +export type UserConfigPasswordLoginDto = { + /** Enabled */ + enabled: boolean; +}; +export type UserConfigReverseGeocodingDto = { + /** Enabled */ + enabled: boolean; +}; +export type UserConfigServerDto = { + /** External domain */ + externalDomain: string; + /** Login page message */ + loginPageMessage: string; + /** Public users */ + publicUsers: boolean; +}; +export type UserConfigThemeDto = { + /** Custom CSS for theming */ + customCss: string; +}; +export type UserConfigTrashDto = { + /** Days */ + days: number; + /** Enabled */ + enabled: boolean; +}; +export type UserConfigUserDto = { + /** Delete delay */ + deleteDelay: number; +}; +export type UserConfigDto = { + ffmpeg: UserConfigFFmpegDto; + image: UserConfigImageDto; + machineLearning: UserConfigMachineLearningDto; + map: UserConfigMapDto; + oauth: UserConfigOAuthDto; + passwordLogin: UserConfigPasswordLoginDto; + reverseGeocoding: UserConfigReverseGeocodingDto; + server: UserConfigServerDto; + theme: UserConfigThemeDto; + trash: UserConfigTrashDto; + user: UserConfigUserDto; +}; export type DownloadArchiveDto = { + /** The name of the archive to download, without extension */ + archiveName?: string; /** Asset IDs */ assetIds: string[]; /** Download edited asset if available */ @@ -1582,6 +2089,32 @@ export type PluginTemplateResponseDto = { /** Ui hints, for example "smart-album" */ uiHints: string[]; }; +export type PublicConfigOAuthDto = { + /** Auto launch */ + autoLaunch: boolean; + /** Button text */ + buttonText: string; + /** Enabled */ + enabled: boolean; +}; +export type PublicConfigPasswordLoginDto = { + /** Enabled */ + enabled: boolean; +}; +export type PublicConfigServerDto = { + /** Login page message */ + loginPageMessage: string; +}; +export type PublicConfigThemeDto = { + /** Custom CSS for theming */ + customCss: string; +}; +export type PublicConfigDto = { + oauth: PublicConfigOAuthDto; + passwordLogin: PublicConfigPasswordLoginDto; + server: PublicConfigServerDto; + theme: PublicConfigThemeDto; +}; export type QueueResponseDto = { /** Whether the queue is paused */ isPaused: boolean; @@ -2009,6 +2542,8 @@ export type ServerConfigDto = { mapLightStyleUrl: string; /** People min faces server default */ minFaces: number; + /** OAuth account management URL */ + oauthAccountManagementUrl?: string; /** OAuth button text */ oauthButtonText: string; /** Whether public user registration is enabled */ @@ -2288,357 +2823,6 @@ export type SyncStreamDto = { /** Sync request types */ types: SyncRequestType[]; }; -export type DatabaseBackupConfig = { - /** Cron expression */ - cronExpression: string; - /** Enabled */ - enabled: boolean; - /** Keep last amount */ - keepLastAmount: number; -}; -export type SystemConfigBackupsDto = { - database: DatabaseBackupConfig; -}; -export type SystemConfigFFmpegRealtimeDto = { - /** Enable real-time HLS transcoding (alpha) */ - enabled: boolean; - /** Resolutions to use for real-time HLS transcoding */ - resolutions: HlsVideoResolution[]; - /** Video codecs to use for real-time HLS transcoding */ - videoCodecs: VideoCodec[]; -}; -export type SystemConfigFFmpegDto = { - accel: TranscodeHWAccel; - /** Accelerated decode */ - accelDecode: boolean; - /** Accepted audio codecs */ - acceptedAudioCodecs: AudioCodec[]; - /** Accepted containers */ - acceptedContainers: VideoContainer[]; - /** Accepted video codecs */ - acceptedVideoCodecs: VideoCodec[]; - /** B-frames */ - bframes: number; - cqMode: CQMode; - /** CRF */ - crf: number; - /** GOP size */ - gopSize: number; - /** Max bitrate */ - maxBitrate: string; - /** Preferred hardware device */ - preferredHwDevice: string; - /** Preset */ - preset: string; - realtime: SystemConfigFFmpegRealtimeDto; - /** References */ - refs: number; - targetAudioCodec: AudioCodec; - /** Target resolution */ - targetResolution: string; - targetVideoCodec: VideoCodec; - /** Temporal AQ */ - temporalAQ: boolean; - /** Threads */ - threads: number; - tonemap: ToneMapping; - transcode: TranscodePolicy; - /** Two pass */ - twoPass: boolean; -}; -export type SystemConfigGeneratedFullsizeImageDto = { - /** Enabled */ - enabled: boolean; - format: ImageFormat; - /** Progressive */ - progressive?: boolean; - /** Quality */ - quality: number; -}; -export type SystemConfigGeneratedImageDto = { - format: ImageFormat; - /** Progressive */ - progressive?: boolean; - /** Quality */ - quality: number; - /** Size */ - size: number; -}; -export type SystemConfigImageDto = { - colorspace: Colorspace; - /** Extract embedded */ - extractEmbedded: boolean; - fullsize: SystemConfigGeneratedFullsizeImageDto; - preview: SystemConfigGeneratedImageDto; - thumbnail: SystemConfigGeneratedImageDto; -}; -export type SystemConfigIntegrityChecksumJob = { - /** Cron expression for when the integrity check should run */ - cronExpression: string; - /** Enabled */ - enabled: boolean; - /** Percentage limit of the integrity checksum job */ - percentageLimit: number; - /** How long the integrity checksum job may run for */ - timeLimit: number; -}; -export type SystemConfigIntegrityJob = { - /** Cron expression for when the integrity check should run */ - cronExpression: string; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigIntegrityChecks = { - checksumFiles: SystemConfigIntegrityChecksumJob; - missingFiles: SystemConfigIntegrityJob; - untrackedFiles: SystemConfigIntegrityJob; -}; -export type JobSettingsDto = { - /** Concurrency */ - concurrency: number; -}; -export type SystemConfigJobDto = { - backgroundTask: JobSettingsDto; - editor: JobSettingsDto; - faceDetection: JobSettingsDto; - integrityCheck: JobSettingsDto; - library: JobSettingsDto; - metadataExtraction: JobSettingsDto; - migration: JobSettingsDto; - notifications: JobSettingsDto; - ocr: JobSettingsDto; - search: JobSettingsDto; - sidecar: JobSettingsDto; - smartSearch: JobSettingsDto; - thumbnailGeneration: JobSettingsDto; - videoConversion: JobSettingsDto; - workflow: JobSettingsDto; -}; -export type SystemConfigLibraryScanDto = { - /** Cron expression */ - cronExpression: string; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigLibraryWatchDto = { - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigLibraryDto = { - scan: SystemConfigLibraryScanDto; - watch: SystemConfigLibraryWatchDto; -}; -export type SystemConfigLoggingDto = { - /** Enabled */ - enabled: boolean; - level: LogLevel; -}; -export type MachineLearningAvailabilityChecksDto = { - /** Enabled */ - enabled: boolean; - interval: number; - timeout: number; -}; -export type ClipConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Name of the model to use */ - modelName: string; -}; -export type DuplicateDetectionConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Maximum distance threshold for duplicate detection */ - maxDistance: number; -}; -export type FacialRecognitionConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Maximum distance threshold for face recognition */ - maxDistance: number; - /** Minimum number of faces required for recognition */ - minFaces: number; - /** Minimum confidence score for face detection */ - minScore: number; - /** Name of the model to use */ - modelName: string; -}; -export type OcrConfig = { - /** Whether the task is enabled */ - enabled: boolean; - /** Maximum resolution for OCR processing */ - maxResolution: number; - /** Minimum confidence score for text detection */ - minDetectionScore: number; - /** Minimum confidence score for text recognition */ - minRecognitionScore: number; - /** Name of the model to use */ - modelName: string; -}; -export type SystemConfigMachineLearningDto = { - availabilityChecks: MachineLearningAvailabilityChecksDto; - clip: ClipConfig; - duplicateDetection: DuplicateDetectionConfig; - /** Enabled */ - enabled: boolean; - facialRecognition: FacialRecognitionConfig; - ocr: OcrConfig; - /** ML service URLs */ - urls: string[]; -}; -export type SystemConfigMapDto = { - /** Dark map style URL */ - darkStyle: string; - /** Enabled */ - enabled: boolean; - /** Light map style URL */ - lightStyle: string; -}; -export type SystemConfigFacesDto = { - /** Import */ - "import": boolean; -}; -export type SystemConfigMetadataDto = { - faces: SystemConfigFacesDto; -}; -export type SystemConfigNewVersionCheckDto = { - channel: ReleaseChannel; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigNightlyTasksDto = { - /** Cluster new faces */ - clusterNewFaces: boolean; - /** Database cleanup */ - databaseCleanup: boolean; - /** Generate memories */ - generateMemories: boolean; - /** Missing thumbnails */ - missingThumbnails: boolean; - /** Start time (HH:MM) */ - startTime: string; - /** Sync quota usage */ - syncQuotaUsage: boolean; -}; -export type SystemConfigNotificationsDto = { - smtp: SystemConfigSmtpDto; -}; -export type SystemConfigOAuthDto = { - /** Allow insecure requests */ - allowInsecureRequests: boolean; - /** Auto launch */ - autoLaunch: boolean; - /** Auto register */ - autoRegister: boolean; - /** Button text */ - buttonText: string; - /** Client ID */ - clientId: string; - /** Client secret */ - clientSecret: string; - /** Default storage quota */ - defaultStorageQuota: number | null; - /** Enabled */ - enabled: boolean; - /** End session endpoint */ - endSessionEndpoint: string; - /** Issuer URL */ - issuerUrl: string; - /** Mobile override enabled */ - mobileOverrideEnabled: boolean; - /** Mobile redirect URI (set to empty string to disable) */ - mobileRedirectUri: string; - /** Profile signing algorithm */ - profileSigningAlgorithm: string; - /** OAuth prompt parameter (e.g. select_account, login, consent) */ - prompt: string; - /** Role claim */ - roleClaim: string; - /** Scope */ - scope: string; - /** Signing algorithm */ - signingAlgorithm: string; - /** Storage label claim */ - storageLabelClaim: string; - /** Storage quota claim */ - storageQuotaClaim: string; - /** Timeout */ - timeout: number; - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; -}; -export type SystemConfigPasswordLoginDto = { - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigReverseGeocodingDto = { - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigServerDto = { - /** External domain */ - externalDomain: string; - /** Login page message */ - loginPageMessage: string; - /** Public users */ - publicUsers: boolean; -}; -export type SystemConfigStorageTemplateDto = { - /** Enabled */ - enabled: boolean; - /** Hash verification enabled */ - hashVerificationEnabled: boolean; - /** Template */ - template: string; -}; -export type SystemConfigTemplateEmailsDto = { - /** Album invite template */ - albumInviteTemplate: string; - /** Album update template */ - albumUpdateTemplate: string; - /** Welcome template */ - welcomeTemplate: string; -}; -export type SystemConfigTemplatesDto = { - email: SystemConfigTemplateEmailsDto; -}; -export type SystemConfigThemeDto = { - /** Custom CSS for theming */ - customCss: string; -}; -export type SystemConfigTrashDto = { - /** Days */ - days: number; - /** Enabled */ - enabled: boolean; -}; -export type SystemConfigUserDto = { - /** Delete delay */ - deleteDelay: number; -}; -export type SystemConfigDto = { - backup: SystemConfigBackupsDto; - ffmpeg: SystemConfigFFmpegDto; - image: SystemConfigImageDto; - integrityChecks: SystemConfigIntegrityChecks; - job: SystemConfigJobDto; - library: SystemConfigLibraryDto; - logging: SystemConfigLoggingDto; - machineLearning: SystemConfigMachineLearningDto; - map: SystemConfigMapDto; - metadata: SystemConfigMetadataDto; - newVersionCheck: SystemConfigNewVersionCheckDto; - nightlyTasks: SystemConfigNightlyTasksDto; - notifications: SystemConfigNotificationsDto; - oauth: SystemConfigOAuthDto; - passwordLogin: SystemConfigPasswordLoginDto; - reverseGeocoding: SystemConfigReverseGeocodingDto; - server: SystemConfigServerDto; - storageTemplate: SystemConfigStorageTemplateDto; - templates: SystemConfigTemplatesDto; - theme: SystemConfigThemeDto; - trash: SystemConfigTrashDto; - user: SystemConfigUserDto; -}; export type SystemConfigTemplateStorageOptionDto = { /** Available day format options for storage template */ dayOptions: string[]; @@ -2791,6 +2975,8 @@ export type WorkflowResponseDto = { enabled: boolean; /** Workflow ID */ id: string; + /** Workflow logs run results */ + logging: boolean; /** Workflow name */ name: string | null; /** Workflow steps */ @@ -2805,6 +2991,8 @@ export type WorkflowCreateDto = { description?: string | null; /** Workflow enabled */ enabled?: boolean; + /** Workflow logs run results */ + logging?: boolean; /** Workflow name */ name?: string | null; steps?: WorkflowStepDto[]; @@ -2822,12 +3010,30 @@ export type WorkflowUpdateDto = { description?: string | null; /** Workflow enabled */ enabled?: boolean; + /** Workflow logs run results */ + logging?: boolean; /** Workflow name */ name?: string | null; steps?: WorkflowStepDto[]; /** Workflow trigger type */ trigger?: WorkflowTrigger; }; +export type WorkflowLogEntryDto = { + /** Workflow run date/time */ + at: string; + /** Workflow log entry ID */ + id: string; + /** Last step ran, if the workflow ended early */ + lastStep?: { + /** Index of the step in the workflow */ + index: number; + /** Method of the step */ + method: string; + }; + result: WorkflowResult; + /** Workflow trigger data ID */ + triggerDataId?: string; +}; export type WorkflowShareStepDto = { /** Step configuration */ config: { @@ -3420,6 +3626,43 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +/** + * Get the admin configuration + */ +export function getAdminConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminConfigDto; + }>("/admin/config", { + ...opts + })); +} +/** + * Update the system configuration + */ +export function updateAdminConfig({ adminConfigDto }: { + adminConfigDto: AdminConfigDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminConfigDto; + }>("/admin/config", oazapfts.json({ + ...opts, + method: "PUT", + body: adminConfigDto + }))); +} +/** + * Get the system configuration defaults + */ +export function getAdminConfigDefaults(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AdminConfigDto; + }>("/admin/config/defaults", { + ...opts + })); +} /** * Delete database backup */ @@ -3627,8 +3870,8 @@ export function getNotificationTemplateAdmin({ name, templateDto }: { /** * Send test email */ -export function sendTestEmailAdmin({ systemConfigSmtpDto }: { - systemConfigSmtpDto: SystemConfigSmtpDto; +export function sendTestEmailAdmin({ adminConfigSmtpDto }: { + adminConfigSmtpDto: AdminConfigSmtpDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3636,7 +3879,7 @@ export function sendTestEmailAdmin({ systemConfigSmtpDto }: { }>("/admin/notifications/test-email", oazapfts.json({ ...opts, method: "POST", - body: systemConfigSmtpDto + body: adminConfigSmtpDto }))); } /** @@ -4090,6 +4333,80 @@ export function updateApiKey({ id, apiKeyUpdateDto }: { body: apiKeyUpdateDto }))); } +/** + * Rotate an API key + */ +export function rotateApiKey({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: ApiKeyCreateResponseDto; + }>(`/api-keys/${encodeURIComponent(id)}/rotate`, { + ...opts, + method: "POST" + })); +} +/** + * Search asset files + */ +export function searchAssetFiles({ assetId, isEdited, isProgressive, isTransparent, $type }: { + assetId: string; + isEdited?: boolean; + isProgressive?: boolean; + isTransparent?: boolean; + $type?: AssetFileType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto[]; + }>(`/asset-files${QS.query(QS.explode({ + assetId, + isEdited, + isProgressive, + isTransparent, + "type": $type + }))}`, { + ...opts + })); +} +/** + * Delete an asset file + */ +export function deleteAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/asset-files/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve an asset file + */ +export function getAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto; + }>(`/asset-files/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Download an asset file + */ +export function downloadAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/asset-files/${encodeURIComponent(id)}/download`, { + ...opts + })); +} /** * Delete assets */ @@ -4649,6 +4966,114 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { method: "POST" })); } +/** + * Retrieve cluster group requests + */ +export function getClusterGroupRequests(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ClusterGroupRequestResponseDto[]; + }>("/cluster-groups/requests", { + ...opts + })); +} +/** + * Decline a cluster group request + */ +export function deleteClusterGroupRequest({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/cluster-groups/requests/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Accept a cluster group request + */ +export function acceptClusterGroupRequest({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/cluster-groups/requests/${encodeURIComponent(id)}/accept`, { + ...opts, + method: "POST" + })); +} +/** + * Leave a cluster group + */ +export function leaveClusterGroup({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/cluster-groups/${encodeURIComponent(id)}/leave`, { + ...opts, + method: "POST" + })); +} +/** + * Retrieve the requests sent by a cluster group + */ +export function getClusterGroupRequestsForGroup({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ClusterGroupRequestResponseDto[]; + }>(`/cluster-groups/${encodeURIComponent(id)}/requests`, { + ...opts + })); +} +/** + * Create a cluster group request + */ +export function createClusterGroupRequest({ id, clusterGroupRequestCreateDto }: { + id: string; + clusterGroupRequestCreateDto: ClusterGroupRequestCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ClusterGroupRequestResponseDto; + }>(`/cluster-groups/${encodeURIComponent(id)}/requests`, oazapfts.json({ + ...opts, + method: "PUT", + body: clusterGroupRequestCreateDto + }))); +} +/** + * Retrieve the users of a cluster group + */ +export function getClusterGroupUsers({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserResponseDto[]; + }>(`/cluster-groups/${encodeURIComponent(id)}/users`, { + ...opts + })); +} +/** + * Get the configuration with user visibility + */ +export function getUserConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserConfigDto; + }>("/config", { + ...opts + })); +} +/** + * Get the default configuration with user visibility + */ +export function getUserConfigDefaults(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: UserConfigDto; + }>("/config/defaults", { + ...opts + })); +} /** * Download asset archive */ @@ -5598,6 +6023,28 @@ export function getPlugin({ id }: { ...opts })); } +/** + * Get the public configuration + */ +export function getPublicConfig(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PublicConfigDto; + }>("/public/config", { + ...opts + })); +} +/** + * Get the public configuration defaults + */ +export function getPublicConfigDefaults(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PublicConfigDto; + }>("/public/config/defaults", { + ...opts + })); +} /** * List all queues */ @@ -6408,7 +6855,7 @@ export function getSyncStream({ syncStreamDto }: { export function getConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SystemConfigDto; + data: AdminConfigDto; }>("/system-config", { ...opts })); @@ -6416,16 +6863,16 @@ export function getConfig(opts?: Oazapfts.RequestOpts) { /** * Update system configuration */ -export function updateConfig({ systemConfigDto }: { - systemConfigDto: SystemConfigDto; +export function updateConfig({ adminConfigDto }: { + adminConfigDto: AdminConfigDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SystemConfigDto; + data: AdminConfigDto; }>("/system-config", oazapfts.json({ ...opts, method: "PUT", - body: systemConfigDto + body: adminConfigDto }))); } /** @@ -6434,7 +6881,7 @@ export function updateConfig({ systemConfigDto }: { export function getConfigDefaults(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: SystemConfigDto; + data: AdminConfigDto; }>("/system-config/defaults", { ...opts })); @@ -6981,10 +7428,11 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { /** * List all workflows */ -export function searchWorkflows({ description, enabled, id, name, trigger }: { +export function searchWorkflows({ description, enabled, id, logging, name, trigger }: { description?: string; enabled?: boolean; id?: string; + logging?: boolean; name?: string; trigger?: WorkflowTrigger; }, opts?: Oazapfts.RequestOpts) { @@ -6995,6 +7443,7 @@ export function searchWorkflows({ description, enabled, id, name, trigger }: { description, enabled, id, + logging, name, trigger }))}`, { @@ -7067,6 +7516,26 @@ export function updateWorkflow({ id, workflowUpdateDto }: { body: workflowUpdateDto }))); } +/** + * Retrieve workflow logs + */ +export function getWorkflowLogs({ before, id, limit, result }: { + before?: string; + id: string; + limit?: number; + result?: WorkflowResult; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowLogEntryDto[]; + }>(`/workflows/${encodeURIComponent(id)}/logs${QS.query(QS.explode({ + before, + limit, + result + }))}`, { + ...opts + })); +} /** * Retrieve a workflow */ @@ -7100,6 +7569,80 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum TranscodeHWAccel { + Nvenc = "nvenc", + Qsv = "qsv", + Vaapi = "vaapi", + Rkmpp = "rkmpp", + Disabled = "disabled" +} +export enum AudioCodec { + Mp3 = "mp3", + Aac = "aac", + Opus = "opus", + PcmS16Le = "pcm_s16le" +} +export enum VideoContainer { + Mov = "mov", + Mp4 = "mp4", + Ogg = "ogg", + Webm = "webm" +} +export enum VideoCodec { + H264 = "h264", + Hevc = "hevc", + Vp9 = "vp9", + Av1 = "av1" +} +export enum CQMode { + Auto = "auto", + Cqp = "cqp", + Icq = "icq" +} +export enum HlsVideoResolution { + $480 = 480, + $720 = 720, + $1080 = 1080, + $1440 = 1440, + $2160 = 2160 +} +export enum ToneMapping { + Hable = "hable", + Mobius = "mobius", + Reinhard = "reinhard", + Disabled = "disabled" +} +export enum TranscodePolicy { + All = "all", + Optimal = "optimal", + Bitrate = "bitrate", + Required = "required", + Disabled = "disabled" +} +export enum Colorspace { + Srgb = "srgb", + P3 = "p3" +} +export enum ImageFormat { + Jpeg = "jpeg", + Webp = "webp" +} +export enum LogLevel { + Verbose = "verbose", + Debug = "debug", + Log = "log", + Warn = "warn", + Error = "error", + Fatal = "fatal" +} +export enum ReleaseChannel { + Stable = "stable", + ReleaseCandidate = "releaseCandidate" +} +export enum OAuthTokenEndpointAuthMethod { + ClientSecretPost = "client_secret_post", + ClientSecretBasic = "client_secret_basic" +} export enum IntegrityReport { UntrackedFile = "untracked_file", MissingFile = "missing_file", @@ -7131,6 +7674,7 @@ export enum NotificationType { SystemMessage = "SystemMessage", AlbumInvite = "AlbumInvite", AlbumUpdate = "AlbumUpdate", + ClusterGroupRequest = "ClusterGroupRequest", Custom = "Custom" } export enum UserStatus { @@ -7175,6 +7719,7 @@ export enum Permission { ApiKeyRead = "apiKey.read", ApiKeyUpdate = "apiKey.update", ApiKeyDelete = "apiKey.delete", + ApiKeyRotate = "apiKey.rotate", AssetRead = "asset.read", AssetUpdate = "asset.update", AssetDelete = "asset.delete", @@ -7185,6 +7730,9 @@ export enum Permission { AssetUpload = "asset.upload", AssetCopy = "asset.copy", AssetDerive = "asset.derive", + AssetFileRead = "assetFile.read", + AssetFileDelete = "assetFile.delete", + AssetFileDownload = "assetFile.download", AssetEditGet = "asset.edit.get", AssetEditCreate = "asset.edit.create", AssetEditDelete = "asset.edit.delete", @@ -7207,6 +7755,14 @@ export enum Permission { BackupDownload = "backup.download", BackupUpload = "backup.upload", BackupDelete = "backup.delete", + ClusterGroupRead = "clusterGroup.read", + ClusterGroupLeave = "clusterGroup.leave", + ClusterGroupRequestCreate = "clusterGroupRequest.create", + ClusterGroupRequestRead = "clusterGroupRequest.read", + ClusterGroupRequestDelete = "clusterGroupRequest.delete", + AdminConfigRead = "adminConfig.read", + AdminConfigUpdate = "adminConfig.update", + UserConfigRead = "userConfig.read", DuplicateRead = "duplicate.read", DuplicateDelete = "duplicate.delete", FaceCreate = "face.create", @@ -7314,6 +7870,7 @@ export enum Permission { WorkflowRead = "workflow.read", WorkflowUpdate = "workflow.update", WorkflowDelete = "workflow.delete", + WorkflowLogs = "workflow.logs", AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", @@ -7321,6 +7878,13 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } +export enum AssetFileType { + Fullsize = "fullsize", + Preview = "preview", + Thumbnail = "thumbnail", + Sidecar = "sidecar", + EncodedVideo = "encoded_video" +} export enum AssetMediaStatus { Created = "created", Duplicate = "duplicate" @@ -7427,7 +7991,8 @@ export enum WorkflowType { } export enum WorkflowTrigger { AssetCreate = "AssetCreate", - AssetMetadataExtraction = "AssetMetadataExtraction" + AssetMetadataExtraction = "AssetMetadataExtraction", + AssetTagged = "AssetTagged" } export enum QueueJobStatus { Active = "active", @@ -7612,84 +8177,15 @@ export enum SyncRequestType { AssetFacesV2 = "AssetFacesV2", UserMetadataV1 = "UserMetadataV1" } -export enum TranscodeHWAccel { - Nvenc = "nvenc", - Qsv = "qsv", - Vaapi = "vaapi", - Rkmpp = "rkmpp", - Disabled = "disabled" -} -export enum AudioCodec { - Mp3 = "mp3", - Aac = "aac", - Opus = "opus", - PcmS16Le = "pcm_s16le" -} -export enum VideoContainer { - Mov = "mov", - Mp4 = "mp4", - Ogg = "ogg", - Webm = "webm" -} -export enum VideoCodec { - H264 = "h264", - Hevc = "hevc", - Vp9 = "vp9", - Av1 = "av1" -} -export enum CQMode { - Auto = "auto", - Cqp = "cqp", - Icq = "icq" -} -export enum HlsVideoResolution { - $480 = 480, - $720 = 720, - $1080 = 1080, - $1440 = 1440, - $2160 = 2160 -} -export enum ToneMapping { - Hable = "hable", - Mobius = "mobius", - Reinhard = "reinhard", - Disabled = "disabled" -} -export enum TranscodePolicy { - All = "all", - Optimal = "optimal", - Bitrate = "bitrate", - Required = "required", - Disabled = "disabled" -} -export enum Colorspace { - Srgb = "srgb", - P3 = "p3" -} -export enum ImageFormat { - Jpeg = "jpeg", - Webp = "webp" -} -export enum LogLevel { - Verbose = "verbose", - Debug = "debug", - Log = "log", - Warn = "warn", - Error = "error", - Fatal = "fatal" -} -export enum ReleaseChannel { - Stable = "stable", - ReleaseCandidate = "releaseCandidate" -} -export enum OAuthTokenEndpointAuthMethod { - ClientSecretPost = "client_secret_post", - ClientSecretBasic = "client_secret_basic" -} export enum AssetOrderBy { TakenAt = "takenAt", CreatedAt = "createdAt" } +export enum WorkflowResult { + Completed = "completed", + Halted = "halted", + Error = "error" +} export enum ReleaseType { Major = "major", Premajor = "premajor", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e147c2502..6d7cbe9c2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: canvas: 3.2.3 - sharp: ^0.34.5 + sharp: ^0.35.3 packageExtensionsChecksum: sha256-W6pFzyf+6QXnV91iA6oob0OGVkergPXDN1afLgoF53k= @@ -79,7 +79,7 @@ importers: version: 19.2.8(react@19.2.8) tailwindcss: specifier: ^3.2.4 - version: 3.4.19(tsx@4.23.9)(yaml@2.9.0) + version: 3.4.19(tsx@4.23.12)(yaml@2.9.0) url: specifier: ^0.11.0 version: 0.11.4 @@ -107,7 +107,7 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.0 - version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@faker-js/faker': specifier: ^10.1.0 version: 10.5.0 @@ -128,13 +128,13 @@ importers: version: 3.1.2 '@types/luxon': specifier: ^3.4.2 - version: 3.7.3 + version: 3.7.4 '@types/node': specifier: ^24.13.3 version: 24.13.3 '@types/pg': specifier: ^8.15.1 - version: 8.20.4 + version: 8.21.0 '@types/pngjs': specifier: ^6.0.4 version: 6.0.5 @@ -149,28 +149,28 @@ importers: version: 17.4.2 eslint: specifier: ^10.0.0 - version: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + version: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) eslint-plugin-unicorn: - specifier: ^72.0.0 - version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + specifier: ^73.0.0 + version: 73.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) exiftool-vendored: specifier: ^35.0.0 version: 35.21.0 globals: specifier: ^17.0.0 - version: 17.9.0 + version: 17.11.0 luxon: specifier: ^3.4.4 version: 3.7.2 pg: specifier: ^8.11.3 - version: 8.22.0 + version: 8.23.0 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -181,8 +181,8 @@ importers: specifier: ^4.0.0 version: 4.3.0(@typescript/typescript6@6.0.2)(prettier@3.9.6) sharp: - specifier: ^0.34.5 - version: 0.34.5 + specifier: ^0.35.3 + version: 0.35.3(@types/node@24.13.3) socket.io-client: specifier: ^4.7.4 version: 4.8.3(supports-color@8.1.1) @@ -194,13 +194,13 @@ importers: version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.28.0 - version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) utimes: specifier: ^5.2.1 version: 5.2.1(supports-color@8.1.1) vitest: specifier: ^4.0.0 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) packages/cli: dependencies: @@ -219,10 +219,13 @@ importers: micromatch: specifier: ^4.0.8 version: 4.0.8 + semver: + specifier: ^7.8.1 + version: 7.8.5 devDependencies: '@eslint/js': specifier: ^10.0.0 - version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@immich/sdk': specifier: workspace:* version: link:../sdk @@ -261,19 +264,19 @@ importers: version: 15.0.0 eslint: specifier: ^10.0.0 - version: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + version: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) eslint-plugin-unicorn: - specifier: ^72.0.0 - version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + specifier: ^73.0.0 + version: 73.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) globals: specifier: ^17.0.0 - version: 17.9.0 + version: 17.11.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -288,13 +291,13 @@ importers: version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.58.0 - version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) vite: specifier: ^8.0.0 - version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.0.0 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) vitest-fetch-mock: specifier: ^0.4.0 version: 0.4.5(vitest@4.1.10) @@ -306,16 +309,16 @@ importers: devDependencies: '@types/oidc-provider': specifier: ^9.0.0 - version: 9.11.0 + version: 9.11.1 jose: specifier: ^6.0.0 - version: 6.2.8 + version: 6.2.9 oidc-provider: specifier: ^9.0.0 - version: 9.11.2(supports-color@8.1.1) + version: 9.11.3(supports-color@8.1.1) tsx: specifier: ^4.20.6 - version: 4.23.9 + version: 4.23.12 packages/plugin-core: devDependencies: @@ -377,10 +380,10 @@ importers: version: 7.8.0 vite: specifier: ^8.0.16 - version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.1.8 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) packages/sdk: dependencies: @@ -408,28 +411,28 @@ importers: version: 0.5.2 '@nestjs/bullmq': specifier: ^11.0.1 - version: 11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(bullmq@5.81.3(supports-color@8.1.1)) + version: 11.0.5(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(bullmq@5.81.3(supports-color@8.1.1)) '@nestjs/common': specifier: ^11.0.4 - version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + version: 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/core': specifier: ^11.0.4 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.4 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(supports-color@8.1.1) + version: 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(supports-color@8.1.1) '@nestjs/platform-socket.io': specifier: ^11.0.4 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.28)(rxjs@7.8.2)(supports-color@8.1.1) + version: 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.29)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/schedule': specifier: ^6.0.0 - version: 6.1.3(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28) + version: 6.1.3(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29) '@nestjs/swagger': specifier: ^11.4.2 - version: 11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2) + version: 11.4.6(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.0.4 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@nestjs/platform-socket.io@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.1 @@ -534,7 +537,7 @@ importers: version: 5.11.1(supports-color@8.1.1) jose: specifier: ^6.0.0 - version: 6.2.8 + version: 6.2.9 js-yaml: specifier: ^5.0.0 version: 5.2.3 @@ -561,28 +564,28 @@ importers: version: 2.2.0 nest-commander: specifier: ^3.16.0 - version: 3.20.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@types/inquirer@8.2.13)(@types/node@24.13.3)(@typescript/typescript6@6.0.2) + version: 3.20.1(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@types/inquirer@8.2.13)(@types/node@24.13.3)(@typescript/typescript6@6.0.2) nestjs-cls: specifier: ^6.0.0 - version: 6.2.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 6.2.1(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) nestjs-kysely: specifier: 3.1.2 - version: 3.1.2(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(kysely@0.28.17)(reflect-metadata@0.2.2) + version: 3.1.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(kysely@0.28.17)(reflect-metadata@0.2.2) nestjs-otel: specifier: ^8.0.0 - version: 8.1.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(rxjs@7.8.2) + version: 8.1.0(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(rxjs@7.8.2) nestjs-zod: specifier: ^5.3.0 - version: 5.5.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/swagger@11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) + version: 5.5.0(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/swagger@11.4.6(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6) nodemailer: specifier: ^9.0.0 - version: 9.0.4 + version: 9.0.5 openid-client: specifier: ^6.3.3 - version: 6.8.4 + version: 6.8.5 pg: specifier: ^8.11.3 - version: 8.22.0 + version: 8.23.0 picomatch: specifier: ^4.0.2 version: 4.0.5 @@ -611,8 +614,8 @@ importers: specifier: ^7.8.1 version: 7.8.5 sharp: - specifier: ^0.34.5 - version: 0.34.5 + specifier: ^0.35.3 + version: 0.35.3(@types/node@24.13.3) sirv: specifier: ^3.0.0 version: 3.0.2 @@ -621,7 +624,7 @@ importers: version: 4.8.3(supports-color@8.1.1) tailwindcss-preset-email: specifier: ^1.4.0 - version: 1.4.1(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)) + version: 1.4.1(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)) thumbhash: specifier: ^0.1.1 version: 0.1.1 @@ -643,7 +646,7 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.0 - version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@nestjs/cli': specifier: ^11.0.2 version: 11.0.24(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/node@24.13.3)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(prettier@3.9.6)(uglify-js@3.19.3) @@ -652,7 +655,7 @@ importers: version: 11.1.0(@typescript/typescript6@6.0.2)(chokidar@4.0.3)(prettier@3.9.6) '@nestjs/testing': specifier: ^11.0.4 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + version: 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@nestjs/platform-express@11.1.29) '@swc/core': specifier: ^1.4.14 version: 1.15.47(@swc/helpers@0.5.23) @@ -691,7 +694,7 @@ importers: version: 4.17.25 '@types/luxon': specifier: ^3.6.2 - version: 3.7.3 + version: 3.7.4 '@types/mock-fs': specifier: ^4.13.1 version: 4.13.4 @@ -730,22 +733,22 @@ importers: version: typescript@7.0.2 '@vitest/coverage-v8': specifier: ^4.0.0 - version: 4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) eslint: specifier: ^10.0.0 - version: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + version: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-prettier: specifier: ^5.1.3 - version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) + version: 5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6) eslint-plugin-unicorn: - specifier: ^72.0.0 - version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + specifier: ^73.0.0 + version: 73.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) globals: specifier: ^17.0.0 - version: 17.9.0 + version: 17.11.0 mock-fs: specifier: ^5.2.0 version: 5.5.0 @@ -766,7 +769,7 @@ importers: version: 7.2.2(supports-color@8.1.1) tailwindcss: specifier: ^3.4.0 - version: 3.4.19(tsx@4.23.9)(yaml@2.9.0) + version: 3.4.19(tsx@4.23.12)(yaml@2.9.0) testcontainers: specifier: ^12.0.0 version: 12.1.0(supports-color@8.1.1) @@ -775,16 +778,16 @@ importers: version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.28.0 - version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) unplugin-swc: specifier: ^1.4.5 - version: 1.5.10(@swc/core@1.15.47(@swc/helpers@0.5.23))(rollup@4.62.0) + version: 1.5.11(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.0)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) vite-tsconfig-paths: specifier: ^6.0.0 - version: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) vitest: specifier: ^3.0.0 - version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) web: dependencies: @@ -799,7 +802,7 @@ importers: version: link:../packages/sdk '@immich/ui': specifier: ^0.85.0 - version: 0.85.0(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 0.85.0(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)) '@mapbox/mapbox-gl-rtl-text': specifier: 0.4.0 version: 0.4.0 @@ -835,7 +838,7 @@ importers: version: 0.42.0 '@zoom-image/svelte': specifier: ^0.3.0 - version: 0.3.9(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 0.3.9(svelte@5.56.9(@typescript-eslint/types@8.67.0)) dom-to-image: specifier: ^2.6.0 version: 2.6.0 @@ -853,13 +856,13 @@ importers: version: 4.7.9 happy-dom: specifier: ^20.0.0 - version: 20.11.1 + version: 20.11.2 hls-video-element: specifier: ^1.5.11 version: 1.5.11 hls.js: specifier: ^1.6.16 - version: 1.6.17 + version: 1.7.0 intl-messageformat: specifier: ^11.0.0 version: 11.2.13 @@ -880,7 +883,7 @@ importers: version: 4.19.2(react@19.2.8) pmtiles: specifier: ^4.3.0 - version: 4.4.1 + version: 4.5.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -895,16 +898,16 @@ importers: version: 5.2.2 svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 4.0.1(svelte@5.56.9(@typescript-eslint/types@8.67.0)) svelte-jsoneditor: specifier: ^3.10.0 - version: 3.13.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 3.13.0(svelte@5.56.9(@typescript-eslint/types@8.67.0)) svelte-maplibre: specifier: ^1.2.5 - version: 1.3.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 1.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0)) svelte-persisted-store: specifier: ^0.12.0 - version: 0.12.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 0.12.0(svelte@5.56.9(@typescript-eslint/types@8.67.0)) tabbable: specifier: ^6.2.0 version: 6.5.0 @@ -926,43 +929,43 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.0 - version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@faker-js/faker': specifier: ^10.0.0 version: 10.5.0 '@koddsson/eslint-plugin-tscompat': specifier: ^0.2.0 - version: 0.2.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 0.2.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) '@socket.io/component-emitter': specifier: ^3.1.0 version: 3.1.2 '@sveltejs/adapter-static': specifier: ^3.0.8 - version: 3.0.10(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))) + version: 3.0.10(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))) '@sveltejs/enhanced-img': specifier: ^0.11.0 - version: 0.11.0(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 0.11.0(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@types/node@24.13.3)(rollup@4.62.0)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@sveltejs/kit': specifier: ^2.56.1 - version: 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@sveltejs/vite-plugin-svelte': - specifier: 7.2.0 - version: 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + specifier: 7.3.0 + version: 7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^7.0.0 - version: 7.0.0(@testing-library/dom@10.4.1) + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10) '@testing-library/svelte': specifier: ^5.2.8 - version: 5.4.2(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))(vitest@4.1.10) + version: 5.4.2(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10) '@testing-library/user-event': specifier: ^14.5.2 - version: 14.6.3(@testing-library/dom@10.4.1) + version: 14.6.4(@testing-library/dom@10.4.1) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 - version: 6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.9(@typescript-eslint/types@8.67.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.9(@typescript-eslint/types@8.67.0)) '@types/chromecast-caf-sender': specifier: ^1.0.11 version: 1.0.11 @@ -977,7 +980,7 @@ importers: version: 4.17.12 '@types/luxon': specifier: ^3.4.2 - version: 3.7.3 + version: 3.7.4 '@types/qrcode': specifier: ^1.5.5 version: 1.5.6 @@ -992,28 +995,28 @@ importers: version: 17.4.2 eslint: specifier: ^10.2.1 - version: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + version: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-better-tailwindcss: specifier: ^4.5.0 - version: 4.7.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(tailwindcss@4.3.3) + version: 4.7.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(tailwindcss@4.3.3) eslint-plugin-compat: specifier: ^7.0.0 - version: 7.0.2(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + version: 7.0.2(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) eslint-plugin-svelte: specifier: ^3.12.4 - version: 3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 3.23.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.9(@typescript-eslint/types@8.67.0)) eslint-plugin-unicorn: - specifier: ^72.0.0 - version: 72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + specifier: ^73.0.0 + version: 73.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) factory.ts: specifier: ^1.4.1 version: 1.4.2 globals: specifier: ^17.0.0 - version: 17.9.0 + version: 17.11.0 prettier: specifier: ^3.7.4 version: 3.9.6 @@ -1022,19 +1025,19 @@ importers: version: 4.2.0(prettier@3.9.6) prettier-plugin-svelte: specifier: ^4.0.0 - version: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 4.1.1(prettier@3.9.6)(svelte@5.56.9(@typescript-eslint/types@8.67.0)) rollup-plugin-visualizer: specifier: ^7.0.0 - version: 7.0.1(rolldown@1.2.3)(rollup@4.62.0) + version: 7.0.1(rolldown@1.2.4)(rollup@4.62.0) svelte: - specifier: 5.56.8 - version: 5.56.8(@typescript-eslint/types@8.66.0) + specifier: 5.56.9 + version: 5.56.9(@typescript-eslint/types@8.67.0) svelte-check: specifier: ^4.4.6 - version: 4.7.4(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + version: 4.7.6(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.9(@typescript-eslint/types@8.67.0)) svelte-eslint-parser: - specifier: ^1.3.3 - version: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + specifier: ^1.8.1 + version: 1.8.1(svelte@5.56.9(@typescript-eslint/types@8.67.0)) tailwindcss: specifier: ^4.2.4 version: 4.3.3 @@ -1043,13 +1046,13 @@ importers: version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.45.0 - version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + version: 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) vite: specifier: ^8.0.0 - version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + version: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) vitest: specifier: ^4.0.0 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) packages: @@ -2948,152 +2951,161 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -3433,16 +3445,6 @@ packages: resolution: {integrity: sha512-Fj4SoA6Ku09EF+t7OEI8QLipA2A+fJCdEOwnDWG84o5jXMRjkcN5NCMH7kFZb5fP62xz914XV5LBOiDdiUXObg==} hasBin: true - '@koa/cors@5.0.0': - resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} - engines: {node: '>= 14.0.0'} - - '@koa/router@15.7.0': - resolution: {integrity: sha512-WaAlk4TOl/O0rhTpOR0l052gz03syPMmI6Pe2gd7v3ubjfv5UcSGcnb0Y/J5NNC/ln+5FiUqPJTc/a15I+XqAA==} - engines: {node: '>= 20'} - peerDependencies: - koa: ^2.0.0 || ^3.0.0 - '@koddsson/eslint-plugin-tscompat@0.2.0': resolution: {integrity: sha512-Oqd4kWSX0LiO9wWHjcmDfXZNC7TotFV/tLRhwCFU3XUeb//KYvJ75c9OmeSJ+vBv5lkCeB+xYsqyNrBc5j18XA==} @@ -3574,18 +3576,18 @@ packages: '@namnode/store@0.1.0': resolution: {integrity: sha512-4NGTldxKcmY0UuZ7OEkvCjs8ZEoeYB6M2UwMu74pdLiFMKxXbj9HdNk1Qn213bxX1O7bY5h+PLh5DZsTURZkYA==} - '@nestjs/bull-shared@11.0.4': - resolution: {integrity: sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==} + '@nestjs/bull-shared@11.0.5': + resolution: {integrity: sha512-QI7GHPk4oePBwszAZn/Kz5hgB79KijB1RDoo+E/BrSt33sHUrvRZXVyO0+knZleyloRYy0LT2VwsqSP/aQvg4Q==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/core': ^10.0.0 || ^11.0.0 - '@nestjs/bullmq@11.0.4': - resolution: {integrity: sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==} + '@nestjs/bullmq@11.0.5': + resolution: {integrity: sha512-u0VGVc5aER8A+4jEYXDin2rtqIHR8sFL68RUgNDgZslvjdeb1QL4CF+Sr0v4zMXJEfaLSVD606Oq0cryNNCxHg==} peerDependencies: '@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/core': ^10.0.0 || ^11.0.0 - bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 '@nestjs/cli@11.0.24': resolution: {integrity: sha512-aIHxQLSYtXShifA3zwWIeznEsZnNa3Iz2QRykFj+sl9IcbERBHr5nH87FRgywM+He3NxoF5WazHfR8FsmVeWxw==} @@ -3600,8 +3602,8 @@ packages: '@swc/core': optional: true - '@nestjs/common@11.1.28': - resolution: {integrity: sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==} + '@nestjs/common@11.1.29': + resolution: {integrity: sha512-zkeNRlfiQIH/044r5zphjNzKYxBRC4O00onTdCsH2qq0R30Ixo0gOS9so5TadEbJJCuGNm0Vx3PBE2FG2vkBdA==} peerDependencies: class-transformer: '>=0.4.1' class-validator: '>=0.13.2' @@ -3613,8 +3615,8 @@ packages: class-validator: optional: true - '@nestjs/core@11.1.28': - resolution: {integrity: sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==} + '@nestjs/core@11.1.29': + resolution: {integrity: sha512-ANXnZxirMNAY+JuRHCIYIdTfbhX3rizeSbJloub/4EYwpIfCuUoaz9woOofWmywYYc3gsVkLxVZV03gqntI8vA==} engines: {node: '>= 20'} peerDependencies: '@nestjs/common': ^11.0.0 @@ -3644,14 +3646,14 @@ packages: class-validator: optional: true - '@nestjs/platform-express@11.1.28': - resolution: {integrity: sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==} + '@nestjs/platform-express@11.1.29': + resolution: {integrity: sha512-OngsAjdzM+SbbmP1XojKfBgl0Lj7u8b7IK5duwpFymzNP5EmjS8ujvqck9M8h0L14I91aRIFSjIpVfHj8WVtqw==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 - '@nestjs/platform-socket.io@11.1.28': - resolution: {integrity: sha512-vY+GmU2jBcymvgm5rEnftUx4qNxK8cDJmXjl1/1NcpITTNJo0vg07xYR43MwXHcMqe7b0jwqt5+UCTzxqQFIqA==} + '@nestjs/platform-socket.io@11.1.29': + resolution: {integrity: sha512-1p2KJpH6CYYpxPrtqpP65VdMgUqobGBPmx8vwOsF84/ki9YWiylG2+1q6kaUqYMtBdjreJDHgabuR5VazXav2Q==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/websockets': ^11.0.0 @@ -3690,8 +3692,8 @@ packages: class-validator: optional: true - '@nestjs/testing@11.1.28': - resolution: {integrity: sha512-B+VgRxeLaH7jkOMgAyUP3N3rpFlisQ7JRxixRbgHvG6a0VgKbbkNSofKExexCgKmQQak80undb3+2kE1lUBmRQ==} + '@nestjs/testing@11.1.29': + resolution: {integrity: sha512-u+aEp90Ly3uQbLoicZs+ZOrJTyvaD0fbd6dpnSgFZz5Bf77gbKKfr+xFE2JxqZuIH15HlBrqDL7/OL2qL8oG+A==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3703,8 +3705,8 @@ packages: '@nestjs/platform-express': optional: true - '@nestjs/websockets@11.1.28': - resolution: {integrity: sha512-jeyclAURCJTN8S8lctDhfLdiJeDKjZmYWWLav653Fb9hl9c+zx5jPhavI8Xk5++R8u+lX9qzaRxtsjEoxTtjyw==} + '@nestjs/websockets@11.1.29': + resolution: {integrity: sha512-Mg0sNK2k+PpamcJpDy9ZP24ZvM08xB6AZs061Kb6dhTesGxr6zrxlbZ7+40r5xwuxD/PN8LM2tSRKf5ojbFNDQ==} peerDependencies: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 @@ -3956,8 +3958,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@oxc-project/types@0.143.0': - resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -4368,92 +4370,92 @@ packages: '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 - '@rolldown/binding-android-arm64@1.2.3': - resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.3': - resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.3': - resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.3': - resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.3': - resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.3': - resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.3': - resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.3': - resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.3': - resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.3': - resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.3': - resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.3': - resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.3': - resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.3': - resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -4691,12 +4693,12 @@ packages: typescript: optional: true - '@sveltejs/load-config@0.2.2': - resolution: {integrity: sha512-K7dsJDQxBOF+f+epuhMactcjK2VP4MRkLKtwSykNtEI+cKVEyzrmmhQ1pmoxI800m4JKcyJ05L2M4yPwAGiBNw==} + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} engines: {node: '>= 18.0.0'} - '@sveltejs/vite-plugin-svelte@7.2.0': - resolution: {integrity: sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==} + '@sveltejs/vite-plugin-svelte@7.3.0': + resolution: {integrity: sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==} engines: {node: ^20.19 || ^22.12 || >=24} peerDependencies: svelte: ^5.46.4 @@ -4978,11 +4980,15 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} - '@testing-library/jest-dom@7.0.0': - resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} engines: {node: '>=22', npm: '>=6', yarn: '>=1'} peerDependencies: '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true '@testing-library/svelte-core@1.1.3': resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} @@ -5003,8 +5009,8 @@ packages: vitest: optional: true - '@testing-library/user-event@14.6.3': - resolution: {integrity: sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==} + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -5328,8 +5334,8 @@ packages: '@types/lodash@4.17.25': resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} - '@types/luxon@3.7.3': - resolution: {integrity: sha512-pE7BSbKHiojpl4v7iEzdfFLXXJaH5RPJxI3Wr4x3HU59l83PJfhcUx4mGPWq245+DCAENAzRF/+ZoYr2tJCe/g==} + '@types/luxon@3.7.4': + resolution: {integrity: sha512-V536ZAd6ZJztrrBlLcDFaaZrXNAL2E5uGmssWf/dpSiLkmkLScXUYhUBnWPmtW+cIqnNHzf6//TCMpIc9SCRRQ==} '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -5367,8 +5373,8 @@ packages: '@types/nodemailer@8.0.1': resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} - '@types/oidc-provider@9.11.0': - resolution: {integrity: sha512-wZMEmCGh8WGKYSUpxJgpPLIKTe4972OJUlWXaNzy5+vvnU097i8ijzApgHLihdg56cQ+PV+USYYcnh3W8kVISA==} + '@types/oidc-provider@9.11.1': + resolution: {integrity: sha512-dO7uYB0gZeYuw+HiMerjtHjN0g0AToO22kmqDegFYuTvS6P2KOGpv46+ILtRBeKoXqWGU/86f/YwJT6HpU1hMw==} '@types/parse5@5.0.3': resolution: {integrity: sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw==} @@ -5379,8 +5385,8 @@ packages: '@types/pg@8.15.6': resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} - '@types/pg@8.20.4': - resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==} + '@types/pg@8.21.0': + resolution: {integrity: sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==} '@types/picomatch@4.0.3': resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} @@ -5487,63 +5493,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.66.0': - resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.66.0 + '@typescript-eslint/parser': ^8.67.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.66.0': - resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.66.0': - resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.66.0': - resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.66.0': - resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.66.0': - resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.66.0': - resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.66.0': - resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.66.0': - resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.66.0': - resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -5994,6 +6000,10 @@ packages: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -6222,8 +6232,8 @@ packages: brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} - brace-expansion@5.0.8: - resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} braces@3.0.3: @@ -7572,8 +7582,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-svelte@3.22.0: - resolution: {integrity: sha512-O3qn0NePTWta+1o25dIThqeEP/hEQ3VxDK2LVO8SQ5wG9umLMvulK+m1yQ4JGOb2Pkl8IB0G1lpRV/HXDXSLTQ==} + eslint-plugin-svelte@3.23.0: + resolution: {integrity: sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 @@ -7582,8 +7592,8 @@ packages: svelte: optional: true - eslint-plugin-unicorn@72.0.0: - resolution: {integrity: sha512-hqO6ksoOHO+ZhdseTuKRVQbx9U7PRO/cv8qAR1mctwzdVO2hYud8uS9luAhp43RJgziYgHAph8eHyipT8GL0ng==} + eslint-plugin-unicorn@73.0.0: + resolution: {integrity: sha512-V0YatLe9nkGhXEXKe2Qljb1EY0sJHwDV0HUF1NKFwtsHh/fU7qGHDgv+6fchzZcgU2/7noHo2gdjnmo0P2uDPw==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -7612,8 +7622,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.8.0: - resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + eslint@10.8.1: + resolution: {integrity: sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -7701,10 +7711,6 @@ packages: resolution: {integrity: sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==} engines: {node: '>=6.0.0'} - eta@4.6.0: - resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} - engines: {node: '>=20'} - etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -7896,8 +7902,8 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - flatted@3.4.3: - resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} fluent-ffmpeg@2.1.3: resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} @@ -8113,8 +8119,8 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.9.0: - resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} + globals@17.11.0: + resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} engines: {node: '>=18'} globalyzer@0.1.0: @@ -8163,8 +8169,8 @@ packages: engines: {node: '>=0.4.7'} hasBin: true - happy-dom@20.11.1: - resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==} + happy-dom@20.11.2: + resolution: {integrity: sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==} engines: {node: '>=20.0.0'} has-flag@4.0.0: @@ -8262,8 +8268,8 @@ packages: hls-video-element@1.5.11: resolution: {integrity: sha512-tJJ65/52CDxj8XFyIve6zT9nVVdUIc6mqvKR25X0ycPKHk07rpjp4xxVteeCefDUBSf/tFLhlICFmn3KWj37xA==} - hls.js@1.6.17: - resolution: {integrity: sha512-NUplVGVuc1hSPwdB/9/cbRkUmLrYi75/hqiXKdA+l300pJNxDu96R7jRb2imDzWJqIUF4I5ThmAdp9GvOCXsuQ==} + hls.js@1.7.0: + resolution: {integrity: sha512-NnQpT6Z//+2IB2qovSUbKbrlXY1MOpi0JynZ9L2NNOuTJiNCyMSQj9k2laEj1v7CxU/Hb8oTWR98eT3mVpPQVQ==} hogan.js@3.0.2: resolution: {integrity: sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==} @@ -8763,8 +8769,8 @@ packages: joi@17.13.4: resolution: {integrity: sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==} - jose@6.2.8: - resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -9240,6 +9246,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.2.0: + resolution: {integrity: sha512-ptco+HFxTLgjafSLim2LojBSwfg5feBjd+SqyiwdGkzC38UPdZy3zgrHMI2CoTf5fJL38tbHMYWVzIH8BxGqJw==} + magicast@0.5.3: resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} @@ -9752,11 +9761,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@6.0.1: - resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} - engines: {node: ^22 || ^24 || >=26} - hasBin: true - napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -9884,8 +9888,8 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} - nodemailer@9.0.4: - resolution: {integrity: sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} engines: {node: '>=6.0.0'} nopt@1.0.10: @@ -9944,8 +9948,8 @@ packages: engines: {node: '>=18'} hasBin: true - oauth4webapi@3.8.6: - resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + oauth4webapi@3.8.7: + resolution: {integrity: sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -9977,8 +9981,8 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} - oidc-provider@9.11.2: - resolution: {integrity: sha512-Z7CZzWtBfcNMSaIoNJwHmEbla6h4mW73soOAJM4Qo9XDALMgQ1J+amMg/FBZJD1BHoec1KLL5BHFDkvlgeUZqw==} + oidc-provider@9.11.3: + resolution: {integrity: sha512-ddS33New8tm08oR7mgkfEx+yaCFM5BwnRDuYkuq60XxE2RtuY2p43Sl6/DyuPwXbYeMpovkrrT/y6QYfg4zNag==} on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} @@ -10015,8 +10019,8 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true - openid-client@6.8.4: - resolution: {integrity: sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==} + openid-client@6.8.5: + resolution: {integrity: sha512-jNGC/5wnTYwCcEUe2ss0IRUmVRQcgxM0A1nLb3eX/9llqNbMWOQd2xd+qDAgfVCpA5Qh96Y1cdnkfbva6+bSdA==} optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} @@ -10238,8 +10242,8 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.22.0: - resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -10302,8 +10306,8 @@ packages: pmtiles@3.2.1: resolution: {integrity: sha512-3R4fBwwoli5mw7a6t1IGwOtfmcSAODq6Okz0zkXhS1zi9sz1ssjjIfslwPvcWw5TNhdjNBUg9fgfPLeqZlH6ng==} - pmtiles@4.4.1: - resolution: {integrity: sha512-5oTeQc/yX/ft1evbpIlnoCZugQuug/iYIAj/ZTqIqzdGek4uZEho99En890EE6NOSI3JTI3IG8R7r8+SltphxA==} + pmtiles@4.5.0: + resolution: {integrity: sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w==} pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} @@ -10738,8 +10742,8 @@ packages: resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} engines: {node: '>=4'} - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + postcss-selector-parser@7.1.5: + resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} engines: {node: '>=4'} postcss-sort-media-queries@5.2.0: @@ -10948,10 +10952,6 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - quick-lru@7.3.0: - resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} - engines: {node: '>=18'} - quickselect@3.0.0: resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} @@ -10989,10 +10989,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - raw-body@4.0.0: - resolution: {integrity: sha512-TMHtwexrgOt9VJ2E5JF9RO8mRXGNgC5wXvKkc5AVSJUt1L5gxvjg7eiCQl96EqUEpCWrnNEkCnbAplYtyuq1IA==} - engines: {node: '>=22'} - raw-loader@4.0.2: resolution: {integrity: sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==} engines: {node: '>= 10.13.0'} @@ -11266,8 +11262,8 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rolldown@1.2.3: - resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -11456,9 +11452,14 @@ packages: resolution: {integrity: sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw==} hasBin: true - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -11778,17 +11779,17 @@ packages: peerDependencies: svelte: '>= 3.43.1 < 6' - svelte-check@4.7.4: - resolution: {integrity: sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} engines: {node: '>= 18.0.0'} hasBin: true peerDependencies: svelte: ^4.0.0 || ^5.0.0-next.0 typescript: ^5.0.0 || ^6.0.0 - svelte-eslint-parser@1.8.0: - resolution: {integrity: sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.1} + svelte-eslint-parser@1.8.1: + resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5} peerDependencies: svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: @@ -11854,8 +11855,8 @@ packages: peerDependencies: svelte: ^5.30.2 - svelte@5.56.8: - resolution: {integrity: sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==} + svelte@5.56.9: + resolution: {integrity: sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==} engines: {node: '>=18'} svg-parser@2.0.4: @@ -12214,8 +12215,8 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - tsx@4.23.9: - resolution: {integrity: sha512-6q8uTORRGauQVjqMQnKUucLFoeXZAfw6zKvG35GLbdKWbLdeOtZ3H4mhyA5mxuUd2o2cRTskhj59nLLQseUvUw==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true @@ -12270,8 +12271,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript-eslint@8.66.0: - resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -12401,14 +12402,43 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - unplugin-swc@1.5.10: - resolution: {integrity: sha512-aNvKH601KYzc/fXyDQJPAk8obNPzA4GdGqx+qSjQoyweMLDMLs5VRbunjL4skIv/hyyAnSgrbxMwXHdRTzhmew==} + unplugin-swc@1.5.11: + resolution: {integrity: sha512-R7U4GbfrESPnYLn3nXREZrwhKQk3BAmTBoKJ5lmo7btTc/yJpNsDzavBWo+XpQTjDSIogiZEAxC5ig/vfMWxWg==} peerDependencies: '@swc/core': ^1.2.108 - unplugin@2.3.11: - resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} - engines: {node: '>=18.12.0'} + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} @@ -12567,8 +12597,8 @@ packages: yaml: optional: true - vite@8.2.0: - resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -14135,9 +14165,9 @@ snapshots: '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)': dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)': dependencies: @@ -14243,9 +14273,9 @@ snapshots: '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)': dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)': dependencies: @@ -14347,7 +14377,7 @@ snapshots: '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)': dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)': dependencies: @@ -14391,13 +14421,13 @@ snapshots: dependencies: postcss: 8.5.26 - '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 - '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.4)': + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.5)': dependencies: - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 '@csstools/utilities@2.0.0(postcss@8.5.26)': dependencies: @@ -15601,9 +15631,9 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))': dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -15629,9 +15659,9 @@ snapshots: mdn-data: 2.29.0 source-map-js: 1.2.1 - '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))': + '@eslint/js@10.0.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))': optionalDependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) '@eslint/object-schema@3.0.5': {} @@ -15708,10 +15738,10 @@ snapshots: dependencies: '@fortawesome/fontawesome-common-types': 7.3.1 - '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)': + '@golevelup/nestjs-discovery@5.0.0(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.18.1 '@grpc/grpc-js@1.14.4': @@ -15765,98 +15795,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.34.5': + '@img/sharp-darwin-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-arm64': 1.3.2 optional: true - '@img/sharp-darwin-x64@0.34.5': + '@img/sharp-darwin-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.3.2 optional: true - '@img/sharp-libvips-darwin-arm64@1.2.4': + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-libvips-darwin-x64@1.2.4': + '@img/sharp-libvips-darwin-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm64@1.2.4': + '@img/sharp-libvips-darwin-x64@1.3.2': optional: true - '@img/sharp-libvips-linux-arm@1.2.4': + '@img/sharp-libvips-linux-arm64@1.3.2': optional: true - '@img/sharp-libvips-linux-ppc64@1.2.4': + '@img/sharp-libvips-linux-arm@1.3.2': optional: true - '@img/sharp-libvips-linux-riscv64@1.2.4': + '@img/sharp-libvips-linux-ppc64@1.3.2': optional: true - '@img/sharp-libvips-linux-s390x@1.2.4': + '@img/sharp-libvips-linux-riscv64@1.3.2': optional: true - '@img/sharp-libvips-linux-x64@1.2.4': + '@img/sharp-libvips-linux-s390x@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + '@img/sharp-libvips-linux-x64@1.3.2': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.2.4': + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': optional: true - '@img/sharp-linux-arm64@0.34.5': + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.3.2 optional: true - '@img/sharp-linux-arm@0.34.5': + '@img/sharp-linux-arm@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.3.2 optional: true - '@img/sharp-linux-ppc64@0.34.5': + '@img/sharp-linux-ppc64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.3.2 optional: true - '@img/sharp-linux-riscv64@0.34.5': + '@img/sharp-linux-riscv64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.3.2 optional: true - '@img/sharp-linux-s390x@0.34.5': + '@img/sharp-linux-s390x@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.3.2 optional: true - '@img/sharp-linux-x64@0.34.5': + '@img/sharp-linux-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.3.2 optional: true - '@img/sharp-linuxmusl-arm64@0.34.5': + '@img/sharp-linuxmusl-arm64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 optional: true - '@img/sharp-linuxmusl-x64@0.34.5': + '@img/sharp-linuxmusl-x64@0.35.3': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 optional: true - '@img/sharp-wasm32@0.34.5': + '@img/sharp-wasm32@0.35.3': dependencies: '@emnapi/runtime': 1.11.1 optional: true - '@img/sharp-win32-arm64@0.34.5': + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 optional: true - '@img/sharp-win32-ia32@0.34.5': + '@img/sharp-win32-arm64@0.35.3': optional: true - '@img/sharp-win32-x64@0.34.5': + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': optional: true '@immich/justified-layout-wasm@0.4.3': {} @@ -15870,16 +15910,16 @@ snapshots: pg-connection-string: 2.14.0 postgres: 3.4.9 - '@immich/ui@0.85.0(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0))': + '@immich/ui@0.85.0(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0))': dependencies: '@internationalized/date': 3.12.2 '@mdi/js': 7.4.47 - '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) - bits-ui: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) + bits-ui: 2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)) fuse.js: 7.5.0 luxon: 3.7.2 simple-icons: 16.28.0 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) svelte-highlight: 7.11.0 tailwind-merge: 3.6.0 tailwind-variants: 3.3.1(tailwind-merge@3.6.0)(tailwindcss@4.3.3) @@ -16221,25 +16261,11 @@ snapshots: '@jsonquerylang/jsonquery@5.1.1': {} - '@koa/cors@5.0.0': - dependencies: - vary: 1.1.2 - - '@koa/router@15.7.0(koa@3.2.1)(supports-color@8.1.1)': - dependencies: - debug: 4.4.3(supports-color@8.1.1) - http-errors: 2.0.1 - koa: 3.2.1 - koa-compose: 4.1.0 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - '@koddsson/eslint-plugin-tscompat@0.2.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@koddsson/eslint-plugin-tscompat@0.2.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@mdn/browser-compat-data': 6.1.5 - '@typescript-eslint/type-utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/type-utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) browserslist: 4.28.6 transitivePeerDependencies: - eslint @@ -16404,17 +16430,17 @@ snapshots: '@namnode/store@0.1.0': {} - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)': + '@nestjs/bull-shared@11.0.5(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(bullmq@5.81.3(supports-color@8.1.1))': + '@nestjs/bullmq@11.0.5(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(bullmq@5.81.3(supports-color@8.1.1))': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28) - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/bull-shared': 11.0.5(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.81.3(supports-color@8.1.1) tslib: 2.8.1 @@ -16456,7 +16482,7 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)': + '@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)': dependencies: file-type: 21.3.4(supports-color@8.1.1) iterare: 1.2.1 @@ -16468,9 +16494,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) fast-safe-stringify: 2.1.1 iterare: 1.2.1 path-to-regexp: 8.4.2 @@ -16479,18 +16505,18 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(supports-color@8.1.1) - '@nestjs/websockets': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/platform-express': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(supports-color@8.1.1) + '@nestjs/websockets': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@nestjs/platform-socket.io@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) reflect-metadata: 0.2.2 - '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(supports-color@8.1.1)': + '@nestjs/platform-express@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(supports-color@8.1.1)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1(supports-color@8.1.1) multer: 2.2.0 @@ -16499,10 +16525,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/platform-socket.io@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.28)(rxjs@7.8.2)(supports-color@8.1.1)': + '@nestjs/platform-socket.io@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.29)(rxjs@7.8.2)(supports-color@8.1.1)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/websockets': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/websockets': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@nestjs/platform-socket.io@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) rxjs: 7.8.2 socket.io: 4.8.3(supports-color@8.1.1) tslib: 2.8.1 @@ -16511,10 +16537,10 @@ snapshots: - supports-color - utf-8-validate - '@nestjs/schedule@6.1.3(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)': + '@nestjs/schedule@6.1.3(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 '@nestjs/schematics@11.1.0(@typescript/typescript6@6.0.2)(chokidar@4.0.3)(prettier@3.9.6)': @@ -16543,12 +16569,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.4.6(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2) js-yaml: 5.2.1 lodash: 4.18.1 path-to-regexp: 8.4.2 @@ -16556,25 +16582,25 @@ snapshots: swagger-ui-dist: 5.32.8 typescript: '@typescript/typescript6@6.0.2' - '@nestjs/testing@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)': + '@nestjs/testing@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@nestjs/platform-express@11.1.29)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(supports-color@8.1.1) + '@nestjs/platform-express': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(supports-color@8.1.1) - '@nestjs/websockets@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@nestjs/platform-socket.io@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/websockets@11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@nestjs/platform-socket.io@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.28)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/platform-socket.io': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/websockets@11.1.29)(rxjs@7.8.2)(supports-color@8.1.1) '@noble/hashes@1.4.0': {} @@ -16871,7 +16897,7 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) - '@oxc-project/types@0.143.0': {} + '@oxc-project/types@0.144.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -17244,46 +17270,46 @@ snapshots: '@codemirror/state': 6.7.1 '@codemirror/view': 6.43.7 - '@rolldown/binding-android-arm64@1.2.3': + '@rolldown/binding-android-arm64@1.2.4': optional: true - '@rolldown/binding-darwin-arm64@1.2.3': + '@rolldown/binding-darwin-arm64@1.2.4': optional: true - '@rolldown/binding-darwin-x64@1.2.3': + '@rolldown/binding-darwin-x64@1.2.4': optional: true - '@rolldown/binding-freebsd-x64@1.2.3': + '@rolldown/binding-freebsd-x64@1.2.4': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.3': + '@rolldown/binding-linux-arm64-gnu@1.2.4': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.3': + '@rolldown/binding-linux-arm64-musl@1.2.4': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.3': + '@rolldown/binding-linux-ppc64-gnu@1.2.4': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.3': + '@rolldown/binding-linux-s390x-gnu@1.2.4': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.3': + '@rolldown/binding-linux-x64-gnu@1.2.4': optional: true - '@rolldown/binding-linux-x64-musl@1.2.3': + '@rolldown/binding-linux-x64-musl@1.2.4': optional: true - '@rolldown/binding-openharmony-arm64@1.2.3': + '@rolldown/binding-openharmony-arm64@1.2.4': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.3': + '@rolldown/binding-win32-arm64-msvc@1.2.4': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.3': + '@rolldown/binding-win32-x64-msvc@1.2.4': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -17427,28 +17453,29 @@ snapshots: dependencies: acorn: 8.18.0 - '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))': + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))': dependencies: - '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) - '@sveltejs/enhanced-img@0.11.0(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(rollup@4.62.0)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': + '@sveltejs/enhanced-img@0.11.0(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@types/node@24.13.3)(rollup@4.62.0)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) magic-string: 0.30.21 - sharp: 0.34.5 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) - svelte-parse-markup: 0.1.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)) - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) - vite-imagetools: 9.0.3(rollup@4.62.0) + sharp: 0.35.3(@types/node@24.13.3) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + svelte-parse-markup: 0.1.5(svelte@5.56.9(@typescript-eslint/types@8.67.0)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) + vite-imagetools: 9.0.3(@types/node@24.13.3)(rollup@4.62.0) zimmerframe: 1.1.4 transitivePeerDependencies: + - '@types/node' - rollup - '@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': + '@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.12(acorn@8.18.0) - '@sveltejs/vite-plugin-svelte': 7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + '@sveltejs/vite-plugin-svelte': 7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@types/cookie': 0.6.0 acorn: 8.18.0 cookie: 0.6.0 @@ -17459,22 +17486,22 @@ snapshots: mrmime: 2.0.1 set-cookie-parser: 3.1.2 sirv: 3.0.2 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) optionalDependencies: '@opentelemetry/api': 1.9.1 typescript: '@typescript/typescript6@6.0.2' - '@sveltejs/load-config@0.2.2': {} + '@sveltejs/load-config@0.2.3': {} - '@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': + '@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: deepmerge: 4.3.1 - magic-string: 0.30.21 + magic-string: 1.2.0 obug: 2.1.3 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7(supports-color@8.1.1))': dependencies: @@ -17699,12 +17726,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': + '@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) '@testing-library/dom@10.4.1': dependencies: @@ -17717,30 +17744,32 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 - '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@4.1.10)': dependencies: '@adobe/css-tools': 4.5.0 '@testing-library/dom': 10.4.1 - aria-query: 5.3.1 + aria-query: 5.3.2 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 picocolors: 1.1.1 redent: 3.0.0 + optionalDependencies: + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) - '@testing-library/svelte-core@1.1.3(svelte@5.56.8(@typescript-eslint/types@8.66.0))': + '@testing-library/svelte-core@1.1.3(svelte@5.56.9(@typescript-eslint/types@8.67.0))': dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) - '@testing-library/svelte@5.4.2(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))(vitest@4.1.10)': + '@testing-library/svelte@5.4.2(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.10)': dependencies: '@testing-library/dom': 10.4.1 - '@testing-library/svelte-core': 1.1.3(svelte@5.56.8(@typescript-eslint/types@8.66.0)) - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + '@testing-library/svelte-core': 1.1.3(svelte@5.56.9(@typescript-eslint/types@8.67.0)) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) optionalDependencies: - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) - '@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -17753,7 +17782,7 @@ snapshots: '@tokenizer/token@0.3.0': {} - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.8(@typescript-eslint/types@8.66.0))': + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.9(@typescript-eslint/types@8.67.0)))(prettier@3.9.6)(supports-color@8.1.1)(svelte@5.56.9(@typescript-eslint/types@8.67.0))': dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 @@ -17765,8 +17794,8 @@ snapshots: parse-imports-exports: 0.2.4 prettier: 3.9.6 optionalDependencies: - prettier-plugin-svelte: 4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)) - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + prettier-plugin-svelte: 4.1.1(prettier@3.9.6)(svelte@5.56.9(@typescript-eslint/types@8.67.0)) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) transitivePeerDependencies: - supports-color @@ -18141,7 +18170,7 @@ snapshots: '@types/lodash@4.17.25': {} - '@types/luxon@3.7.3': {} + '@types/luxon@3.7.4': {} '@types/mdast@4.0.4': dependencies: @@ -18181,7 +18210,7 @@ snapshots: dependencies: '@types/node': 24.13.3 - '@types/oidc-provider@9.11.0': + '@types/oidc-provider@9.11.1': dependencies: '@types/keygrip': 1.0.6 '@types/koa': 3.0.3 @@ -18191,7 +18220,7 @@ snapshots: '@types/pg-pool@2.0.7': dependencies: - '@types/pg': 8.20.4 + '@types/pg': 8.21.0 '@types/pg@8.15.6': dependencies: @@ -18199,7 +18228,7 @@ snapshots: pg-protocol: 1.16.0 pg-types: 2.2.0 - '@types/pg@8.20.4': + '@types/pg@8.21.0': dependencies: '@types/node': 24.13.3 pg-protocol: 1.16.0 @@ -18333,15 +18362,15 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/visitor-keys': 8.66.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + '@typescript-eslint/parser': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/visitor-keys': 8.67.0 + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) @@ -18349,56 +18378,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/parser@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': + '@typescript-eslint/project-service@8.67.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/tsconfig-utils': 8.67.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.67.0 debug: 4.4.3(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.66.0': + '@typescript-eslint/scope-manager@8.67.0': dependencies: - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 - '@typescript-eslint/tsconfig-utils@8.66.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.67.0(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/type-utils@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.66.0': {} + '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': + '@typescript-eslint/typescript-estree@8.67.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1)': dependencies: - '@typescript-eslint/project-service': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/visitor-keys': 8.66.0 + '@typescript-eslint/project-service': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/tsconfig-utils': 8.67.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.6 semver: 7.8.5 @@ -18408,20 +18437,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': + '@typescript-eslint/utils@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) - '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.66.0': + '@typescript-eslint/visitor-keys@8.67.0': dependencies: - '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/types': 8.67.0 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -18499,7 +18528,7 @@ snapshots: dependencies: valibot: 1.4.2(@typescript/typescript6@6.0.2) - '@vitest/coverage-v8@4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': + '@vitest/coverage-v8@4.1.10(vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.10 @@ -18511,7 +18540,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vitest: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: @@ -18525,7 +18554,7 @@ snapshots: obug: 2.1.3 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/expect@3.2.7': dependencies: @@ -18544,21 +18573,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@3.2.7': dependencies: @@ -18694,10 +18723,10 @@ snapshots: dependencies: '@namnode/store': 0.1.0 - '@zoom-image/svelte@0.3.9(svelte@5.56.8(@typescript-eslint/types@8.66.0))': + '@zoom-image/svelte@0.3.9(svelte@5.56.9(@typescript-eslint/types@8.67.0))': dependencies: '@zoom-image/core': 0.42.0 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) abbrev@1.1.1: {} @@ -18893,6 +18922,8 @@ snapshots: aria-query@5.3.1: {} + aria-query@5.3.2: {} + array-flatten@1.1.1: {} array-source@0.0.4: {} @@ -19063,15 +19094,15 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + bits-ui@2.18.1(@internationalized/date@3.12.2)(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: '@floating-ui/core': 1.8.0 '@floating-ui/dom': 1.8.0 '@internationalized/date': 3.12.2 esm-env: 1.2.2 - runed: 0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) - svelte: 5.56.8(@typescript-eslint/types@8.66.0) - svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + runed: 0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)) tabbable: 6.5.0 transitivePeerDependencies: - '@sveltejs/kit' @@ -19151,7 +19182,7 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.8: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -19685,7 +19716,7 @@ snapshots: cron@4.4.0: dependencies: - '@types/luxon': 3.7.3 + '@types/luxon': 3.7.4 luxon: 3.7.2 cross-spawn@7.0.6: @@ -19701,7 +19732,7 @@ snapshots: css-blank-pseudo@7.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 css-declaration-sorter@7.4.0(postcss@8.5.26): dependencies: @@ -19709,9 +19740,9 @@ snapshots: css-has-pseudo@7.0.3(postcss@8.5.26): dependencies: - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 css-loader@6.11.0(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): @@ -20546,11 +20577,11 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) - eslint-plugin-better-tailwindcss@4.7.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(tailwindcss@4.3.3): + eslint-plugin-better-tailwindcss@4.7.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(tailwindcss@4.3.3): dependencies: '@eslint/css-tree': 4.0.5 '@valibot/to-json-schema': 1.7.1(valibot@1.4.2(@typescript/typescript6@6.0.2)) @@ -20562,37 +20593,37 @@ snapshots: tsconfig-paths-webpack-plugin: 4.2.0 valibot: 1.4.2(@typescript/typescript6@6.0.2) optionalDependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) transitivePeerDependencies: - '@eslint/css' - typescript - eslint-plugin-compat@7.0.2(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-plugin-compat@7.0.2(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: '@mdn/browser-compat-data': 6.1.5 ast-metadata-inferer: 0.8.1 browserslist: 4.28.6 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) find-up: 5.0.0 globals: 15.15.0 lodash.memoize: 4.1.2 semver: 7.8.5 - eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6): + eslint-plugin-prettier@5.5.6(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)))(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(prettier@3.9.6): dependencies: - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) prettier: 3.9.6 prettier-linter-helpers: 1.0.1 synckit: 0.11.13 optionalDependencies: '@types/eslint': 9.6.1 - eslint-config-prettier: 10.1.8(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + eslint-config-prettier: 10.1.8(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) - eslint-plugin-svelte@3.22.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + eslint-plugin-svelte@3.23.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) esutils: 2.0.3 globals: 16.5.0 known-css-properties: 0.37.0 @@ -20600,15 +20631,15 @@ snapshots: postcss-load-config: 3.1.4(postcss@8.5.26) postcss-safe-parser: 7.0.1(postcss@8.5.26) semver: 7.8.5 - svelte-eslint-parser: 1.8.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + svelte-eslint-parser: 1.8.1(svelte@5.56.9(@typescript-eslint/types@8.67.0)) optionalDependencies: - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) transitivePeerDependencies: - ts-node - eslint-plugin-unicorn@72.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)): + eslint-plugin-unicorn@73.0.0(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@eslint/css-tree': 4.0.5 browserslist: 4.28.6 change-case: 5.4.4 @@ -20616,9 +20647,9 @@ snapshots: core-js-compat: 3.49.0 detect-indent: 7.0.2 entities: 4.5.0 - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) find-up-simple: 1.0.1 - globals: 17.9.0 + globals: 17.11.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 is-identifier: 1.1.0 @@ -20653,9 +20684,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1): + eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5(supports-color@8.1.1) '@eslint/config-helpers': 0.7.0 @@ -20717,11 +20748,11 @@ snapshots: dependencies: estraverse: 5.3.0 - esrap@2.2.13(@typescript-eslint/types@8.66.0): + esrap@2.2.13(@typescript-eslint/types@8.67.0): dependencies: '@jridgewell/sourcemap-codec': 1.5.5 optionalDependencies: - '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/types': 8.67.0 esrecurse@4.3.0: dependencies: @@ -20774,8 +20805,6 @@ snapshots: eta@2.2.0: {} - eta@4.6.0: {} - etag@1.8.1: {} eval@0.1.8: @@ -20820,7 +20849,7 @@ snapshots: exiftool-vendored@35.21.0: dependencies: '@photostructure/tz-lookup': 11.5.0 - '@types/luxon': 3.7.3 + '@types/luxon': 3.7.4 batch-cluster: 17.3.1 exiftool-vendored.pl: 13.59.0 he: 1.2.0 @@ -21050,12 +21079,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.3 + flatted: 3.4.4 keyv: 4.5.4 flat@5.0.2: {} - flatted@3.4.3: {} + flatted@3.4.4: {} fluent-ffmpeg@2.1.3: dependencies: @@ -21270,7 +21299,7 @@ snapshots: globals@16.5.0: {} - globals@17.9.0: {} + globals@17.11.0: {} globalyzer@0.1.0: {} @@ -21332,7 +21361,7 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 - happy-dom@20.11.1: + happy-dom@20.11.2: dependencies: '@types/node': 24.13.3 '@types/whatwg-mimetype': 3.0.2 @@ -21527,10 +21556,10 @@ snapshots: hls-video-element@1.5.11: dependencies: custom-media-element: 1.4.6 - hls.js: 1.6.17 + hls.js: 1.7.0 media-tracks: 0.3.5 - hls.js@1.6.17: {} + hls.js@1.7.0: {} hogan.js@3.0.2: dependencies: @@ -22034,7 +22063,7 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 - jose@6.2.8: {} + jose@6.2.9: {} js-tokens@10.0.0: {} @@ -22476,6 +22505,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.2.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: dependencies: '@babel/parser': 7.29.7 @@ -23146,7 +23179,7 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.8 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: @@ -23270,8 +23303,6 @@ snapshots: nanoid@3.3.18: {} - nanoid@6.0.1: {} - napi-build-utils@2.0.0: optional: true @@ -23294,12 +23325,12 @@ snapshots: neo-async@2.6.2: {} - nest-commander@3.20.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@types/inquirer@8.2.13)(@types/node@24.13.3)(@typescript/typescript6@6.0.2): + nest-commander@3.20.1(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@types/inquirer@8.2.13)(@types/node@24.13.3)(@typescript/typescript6@6.0.2): dependencies: '@fig/complete-commander': 3.2.0(commander@11.1.0) - '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28) - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/inquirer': 8.2.13 commander: 11.1.0 cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2) @@ -23308,38 +23339,38 @@ snapshots: - '@types/node' - typescript - nestjs-cls@6.2.1(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2): + nestjs-cls@6.2.1(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - nestjs-kysely@3.1.2(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(kysely@0.28.17)(reflect-metadata@0.2.2): + nestjs-kysely@3.1.2(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(kysely@0.28.17)(reflect-metadata@0.2.2): dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) kysely: 0.28.17 reflect-metadata: 0.2.2 tslib: 2.8.1 - nestjs-otel@8.1.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(rxjs@7.8.2): + nestjs-otel@8.1.0(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(rxjs@7.8.2): dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.28)(@nestjs/websockets@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/core': 11.1.29(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.1.29)(@nestjs/websockets@11.1.29)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.1 '@opentelemetry/host-metrics': 0.38.3(@opentelemetry/api@1.9.1) rxjs: 7.8.2 tslib: 2.8.1 - nestjs-zod@5.5.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/swagger@11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6): + nestjs-zod@5.5.0(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/swagger@11.4.6(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(rxjs@7.8.2)(zod@4.3.6): dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) + '@nestjs/common': 11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) deepmerge: 4.3.1 rxjs: 7.8.2 zod: 4.3.6 optionalDependencies: - '@nestjs/swagger': 11.4.6(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.28)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2) + '@nestjs/swagger': 11.4.6(@nestjs/common@11.1.29(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.1.29)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2) next-tick@1.1.0: {} @@ -23399,7 +23430,7 @@ snapshots: node-releases@2.0.51: {} - nodemailer@9.0.4: {} + nodemailer@9.0.5: {} nopt@1.0.10: dependencies: @@ -23453,7 +23484,7 @@ snapshots: pathe: 2.0.3 tinyexec: 1.2.4 - oauth4webapi@3.8.6: {} + oauth4webapi@3.8.7: {} object-assign@4.1.1: {} @@ -23478,18 +23509,11 @@ snapshots: obug@2.1.3: {} - oidc-provider@9.11.2(supports-color@8.1.1): + oidc-provider@9.11.3(supports-color@8.1.1): dependencies: - '@koa/cors': 5.0.0 - '@koa/router': 15.7.0(koa@3.2.1)(supports-color@8.1.1) debug: 4.4.3(supports-color@8.1.1) - eta: 4.6.0 - jose: 6.2.8 - jsesc: 3.1.0 + jose: 6.2.9 koa: 3.2.1 - nanoid: 6.0.1 - quick-lru: 7.3.0 - raw-body: 4.0.0 transitivePeerDependencies: - supports-color @@ -23535,10 +23559,10 @@ snapshots: opener@1.5.2: {} - openid-client@6.8.4: + openid-client@6.8.5: dependencies: - jose: 6.2.8 - oauth4webapi: 3.8.6 + jose: 6.2.9 + oauth4webapi: 3.8.7 optionator@0.9.4: dependencies: @@ -23763,9 +23787,9 @@ snapshots: pg-int8@1.0.1: {} - pg-pool@3.14.0(pg@8.22.0): + pg-pool@3.14.0(pg@8.23.0): dependencies: - pg: 8.22.0 + pg: 8.23.0 pg-protocol@1.16.0: {} @@ -23777,10 +23801,10 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.22.0: + pg@8.23.0: dependencies: pg-connection-string: 2.14.0 - pg-pool: 3.14.0(pg@8.22.0) + pg-pool: 3.14.0(pg@8.23.0) pg-protocol: 1.16.0 pg-types: 2.2.0 pgpass: 1.0.5 @@ -23835,7 +23859,7 @@ snapshots: '@types/leaflet': 1.9.21 fflate: 0.8.3 - pmtiles@4.4.1: + pmtiles@4.5.0: dependencies: fflate: 0.8.3 @@ -23857,7 +23881,7 @@ snapshots: postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-calc@9.0.1(postcss@8.5.26): dependencies: @@ -23928,12 +23952,12 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-dir-pseudo-class@9.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-discard-comments@6.0.2(postcss@8.5.26): dependencies: @@ -23966,12 +23990,12 @@ snapshots: postcss-focus-visible@10.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-focus-within@9.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-font-variant@5.0.0(postcss@8.5.26): dependencies: @@ -24015,13 +24039,13 @@ snapshots: optionalDependencies: postcss: 8.5.26 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.9)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.26 - tsx: 4.23.9 + tsx: 4.23.12 yaml: 2.9.0 postcss-loader@7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.26)(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): @@ -24091,13 +24115,13 @@ snapshots: dependencies: icss-utils: 5.1.0(postcss@8.5.26) postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 postcss-modules-scope@3.2.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-modules-values@4.0.0(postcss@8.5.26): dependencies: @@ -24111,10 +24135,10 @@ snapshots: postcss-nesting@13.0.2(postcss@8.5.26): dependencies: - '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.4) - '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.4) + '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.5) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-normalize-charset@6.0.2(postcss@8.5.26): dependencies: @@ -24263,7 +24287,7 @@ snapshots: postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-reduce-idents@6.0.3(postcss@8.5.26): dependencies: @@ -24296,14 +24320,14 @@ snapshots: postcss-selector-not@8.0.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-selector-parser@6.1.4: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@7.1.4: + postcss-selector-parser@7.1.5: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -24383,10 +24407,10 @@ snapshots: dependencies: prettier: 3.9.6 - prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: prettier: 3.9.6 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) prettier@3.9.6: {} @@ -24506,8 +24530,6 @@ snapshots: quick-lru@5.1.1: {} - quick-lru@7.3.0: {} - quickselect@3.0.0: {} quote-js-string@0.1.0: {} @@ -24543,11 +24565,6 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 - raw-body@4.0.0: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - raw-loader@4.0.2(webpack@5.108.4(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: loader-utils: 2.0.4 @@ -24913,34 +24930,34 @@ snapshots: robust-predicates@3.0.3: {} - rolldown@1.2.3: + rolldown@1.2.4: dependencies: - '@oxc-project/types': 0.143.0 + '@oxc-project/types': 0.144.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.3 - '@rolldown/binding-darwin-arm64': 1.2.3 - '@rolldown/binding-darwin-x64': 1.2.3 - '@rolldown/binding-freebsd-x64': 1.2.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 - '@rolldown/binding-linux-arm64-gnu': 1.2.3 - '@rolldown/binding-linux-arm64-musl': 1.2.3 - '@rolldown/binding-linux-ppc64-gnu': 1.2.3 - '@rolldown/binding-linux-s390x-gnu': 1.2.3 - '@rolldown/binding-linux-x64-gnu': 1.2.3 - '@rolldown/binding-linux-x64-musl': 1.2.3 - '@rolldown/binding-openharmony-arm64': 1.2.3 - '@rolldown/binding-win32-arm64-msvc': 1.2.3 - '@rolldown/binding-win32-x64-msvc': 1.2.3 + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 - rollup-plugin-visualizer@7.0.1(rolldown@1.2.3)(rollup@4.62.0): + rollup-plugin-visualizer@7.0.1(rolldown@1.2.4)(rollup@4.62.0): dependencies: open: 11.0.0 picomatch: 4.0.5 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: - rolldown: 1.2.3 + rolldown: 1.2.4 rollup: 4.62.0 rollup@4.62.0: @@ -25009,14 +25026,14 @@ snapshots: dependencies: queue-microtask: 1.2.3 - runed@0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + runed@0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: dequal: 2.0.3 esm-env: 1.2.2 lz-string: 1.5.0 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) optionalDependencies: - '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) rw@1.3.3: {} @@ -25208,7 +25225,7 @@ snapshots: stream-source: 0.3.5 text-encoding: 0.6.4 - sharp@0.34.5: + sharp@0.35.3(@types/node@24.13.3): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 @@ -25216,30 +25233,32 @@ snapshots: node-gyp: 13.0.0 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 24.13.3 shebang-command@2.0.0: dependencies: @@ -25610,34 +25629,34 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-awesome@3.3.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-awesome@3.3.5(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) - svelte-check@4.7.4(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-check@4.7.6(@typescript/typescript6@6.0.2)(picomatch@4.0.5)(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@sveltejs/load-config': 0.2.2 + '@sveltejs/load-config': 0.2.3 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.5) picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - picomatch - svelte-eslint-parser@1.8.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-eslint-parser@1.8.1(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 postcss: 8.5.26 postcss-scss: 4.0.9(postcss@8.5.26) - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 semver: 7.8.5 optionalDependencies: - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) svelte-floating-ui@1.5.8: dependencies: @@ -25650,7 +25669,7 @@ snapshots: dependencies: highlight.js: 11.11.1 - svelte-i18n@4.0.1(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-i18n@4.0.1(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -25658,10 +25677,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) tiny-glob: 0.2.9 - svelte-jsoneditor@3.13.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-jsoneditor@3.13.0(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: '@codemirror/autocomplete': 6.20.3 '@codemirror/commands': 6.10.4 @@ -25688,42 +25707,42 @@ snapshots: memoize-one: 6.0.0 natural-compare-lite: 1.4.0 sass: 1.102.0 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) - svelte-awesome: 3.3.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) + svelte-awesome: 3.3.5(svelte@5.56.9(@typescript-eslint/types@8.67.0)) svelte-select: 5.8.3 vanilla-picker: 2.12.3 - svelte-maplibre@1.3.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-maplibre@1.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 just-compare: 2.3.0 maplibre-gl: 5.24.0 pmtiles: 3.2.1 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) - svelte-parse-markup@0.1.5(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-parse-markup@0.1.5(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) - svelte-persisted-store@0.12.0(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-persisted-store@0.12.0(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) svelte-select@5.8.3: dependencies: svelte-floating-ui: 1.5.8 - svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)): + svelte-toolbelt@0.10.6(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)): dependencies: clsx: 2.1.1 - runed: 0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.8(@typescript-eslint/types@8.66.0))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)))(svelte@5.56.8(@typescript-eslint/types@8.66.0)) + runed: 0.35.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(@typescript/typescript6@6.0.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.9(@typescript-eslint/types@8.67.0)) style-to-object: 1.0.14 - svelte: 5.56.8(@typescript-eslint/types@8.66.0) + svelte: 5.56.9(@typescript-eslint/types@8.67.0) transitivePeerDependencies: - '@sveltejs/kit' - svelte@5.56.8(@typescript-eslint/types@8.66.0): + svelte@5.56.9(@typescript-eslint/types@8.67.0): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.5.5 @@ -25736,7 +25755,7 @@ snapshots: clsx: 2.1.1 devalue: 5.9.0 esm-env: 1.2.2 - esrap: 2.2.13(@typescript-eslint/types@8.66.0) + esrap: 2.2.13(@typescript-eslint/types@8.67.0) is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 @@ -25784,21 +25803,21 @@ snapshots: tailwind-merge: 3.6.0 tailwindcss: 4.3.3 - tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)): + tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)): dependencies: - tailwindcss: 3.4.19(tsx@4.23.9)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) - tailwindcss-mso@2.0.3(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)): + tailwindcss-mso@2.0.3(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)): dependencies: - tailwindcss: 3.4.19(tsx@4.23.9)(yaml@2.9.0) + tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) - tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)): + tailwindcss-preset-email@1.4.1(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)): dependencies: - tailwindcss: 3.4.19(tsx@4.23.9)(yaml@2.9.0) - tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)) - tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0)) + tailwindcss: 3.4.19(tsx@4.23.12)(yaml@2.9.0) + tailwindcss-email-variants: 3.0.5(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)) + tailwindcss-mso: 2.0.3(tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0)) - tailwindcss@3.4.19(tsx@4.23.9)(yaml@2.9.0): + tailwindcss@3.4.19(tsx@4.23.12)(yaml@2.9.0): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -25817,7 +25836,7 @@ snapshots: postcss: 8.5.26 postcss-import: 15.1.0(postcss@8.5.26) postcss-js: 4.1.0(postcss@8.5.26) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.9)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.23.12)(yaml@2.9.0) postcss-nested: 6.2.0(postcss@8.5.26) postcss-selector-parser: 6.1.4 resolve: 1.22.12 @@ -26123,7 +26142,7 @@ snapshots: tsscmp@1.0.6: {} - tsx@4.23.9: + tsx@4.23.12: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -26175,13 +26194,13 @@ snapshots: typedarray@0.0.6: {} - typescript-eslint@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): + typescript-eslint@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) - '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) - eslint: 10.8.0(jiti@2.7.0)(supports-color@8.1.1) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/parser': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + '@typescript-eslint/typescript-estree': 8.67.0(@typescript/typescript6@6.0.2)(supports-color@8.1.1) + '@typescript-eslint/utils': 8.67.0(@typescript/typescript6@6.0.2)(eslint@10.8.1(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1) + eslint: 10.8.1(jiti@2.7.0)(supports-color@8.1.1) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -26329,21 +26348,34 @@ snapshots: unpipe@1.0.0: {} - unplugin-swc@1.5.10(@swc/core@1.15.47(@swc/helpers@0.5.23))(rollup@4.62.0): + unplugin-swc@1.5.11(@swc/core@1.15.47(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.0)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.62.0) '@swc/core': 1.15.47(@swc/helpers@0.5.23) load-tsconfig: 0.2.5 - unplugin: 2.3.11 + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.0)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)) transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown - rollup + - unloader + - vite + - webpack - unplugin@2.3.11: + unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.4)(rollup@4.62.0)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0))(webpack@5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3)): dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.18.0 picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 + optionalDependencies: + esbuild: 0.28.2 + rolldown: 1.2.4 + rollup: 4.62.0 + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) + webpack: 5.106.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(uglify-js@3.19.3) update-browserslist-db@1.2.3(browserslist@4.28.6): dependencies: @@ -26455,21 +26487,22 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-imagetools@9.0.3(rollup@4.62.0): + vite-imagetools@9.0.3(@types/node@24.13.3)(rollup@4.62.0): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.62.0) imagetools-core: 9.1.0 - sharp: 0.34.5 + sharp: 0.35.3(@types/node@24.13.3) transitivePeerDependencies: + - '@types/node' - rollup - vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): + vite-node@3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -26484,17 +26517,17 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: debug: 4.4.3(supports-color@8.1.1) globrex: 0.1.2 tsconfck: 3.1.6(@typescript/typescript6@6.0.2) - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): + vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.5) @@ -26509,15 +26542,15 @@ snapshots: lightningcss: 1.33.0 sass: 1.102.0 terser: 5.49.0 - tsx: 4.23.9 + tsx: 4.23.12 yaml: 2.9.0 - vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): + vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 postcss: 8.5.26 - rolldown: 1.2.3 + rolldown: 1.2.4 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 @@ -26526,22 +26559,22 @@ snapshots: jiti: 2.7.0 sass: 1.102.0 terser: 5.49.0 - tsx: 4.23.9 + tsx: 4.23.12 yaml: 2.9.0 - vitefu@1.1.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)): optionalDependencies: - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) vitest-fetch-mock@0.4.5(vitest@4.1.10): dependencies: - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) - vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.1)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.2)(jiti@2.7.0)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -26559,13 +26592,13 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(supports-color@8.1.1)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 '@types/node': 24.13.3 - happy-dom: 20.11.1 + happy-dom: 20.11.2 jsdom: 26.1.0(canvas@3.2.3)(supports-color@8.1.1) transitivePeerDependencies: - jiti @@ -26581,10 +26614,10 @@ snapshots: - tsx - yaml - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3)(supports-color@8.1.1))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -26601,21 +26634,21 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - happy-dom: 20.11.1 + happy-dom: 20.11.2 jsdom: 26.1.0(canvas@3.2.3)(supports-color@8.1.1) transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.1)(jsdom@26.1.0(canvas@3.2.3))(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(happy-dom@20.11.2)(jsdom@26.1.0(canvas@3.2.3))(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -26632,13 +26665,13 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.9)(yaml@2.9.0) + vite: 8.2.1(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.3 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - happy-dom: 20.11.1 + happy-dom: 20.11.2 jsdom: 26.1.0(canvas@3.2.3) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 884a4b5b7f..71a80eb969 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,7 +29,7 @@ allowBuilds: postman-code-generators: false overrides: canvas: 3.2.3 - sharp: ^0.34.5 + sharp: ^0.35.3 packageExtensions: nestjs-kysely: dependencies: diff --git a/server/Dockerfile b/server/Dockerfile index 9d5eb1cccb..95f842e658 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS builder +FROM ghcr.io/immich-app/base-server-dev:202608111116@sha256:f53e6bc2aa6e4c7b043e6ec1740448af4ba69e2090411daa7fa1df288bc0131a AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -9,79 +9,84 @@ RUN npm install --global corepack@latest && \ corepack enable pnpm && \ pnpm config set store-dir "$PNPM_HOME" -FROM builder AS server - WORKDIR /usr/src/app -COPY ./server ./server/ -COPY ./packages/sdk ./packages/sdk/ -COPY ./packages/plugin-sdk ./packages/plugin-sdk/ -RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter immich build && \ - SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --prod --no-optional deploy /output/server-pruned -FROM builder AS web +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cjs ./ + +FROM builder AS sdk + +COPY ./packages/sdk/package.json ./packages/sdk/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/sdk install --frozen-lockfile + +COPY ./packages/sdk ./packages/sdk/ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/sdk build + +FROM sdk AS plugin-sdk + +COPY ./packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/plugin-sdk install --frozen-lockfile + +COPY ./packages/plugin-sdk ./packages/plugin-sdk/ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/plugin-sdk build + +FROM plugin-sdk AS server + +COPY ./server/package.json ./server/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter immich install --frozen-lockfile + +COPY ./server ./server/ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ + pnpm --filter immich build && \ + pnpm --filter immich --prod --no-optional deploy /output/server-pruned +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ + SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --dir /output/server-pruned/node_modules/sharp exec npm run build + +FROM sdk AS web + +COPY ./web/package.json ./web/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter immich-web install --frozen-lockfile + +COPY ./i18n ./i18n/ +COPY ./web ./web/ ARG BUILD_ID ENV IMMICH_BUILD=${BUILD_ID} -WORKDIR /usr/src/app -COPY ./web ./web/ -COPY ./i18n ./i18n/ -COPY ./packages ./packages/ -RUN --mount=type=cache,id=pnpm-web,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web install --frozen-lockfile --force && \ - pnpm --filter @immich/sdk --filter immich-web build +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter immich-web build -FROM builder AS cli +FROM sdk AS cli -COPY ./packages ./packages/ -RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && \ - pnpm --filter @immich/sdk --filter @immich/cli build && \ +COPY ./packages/cli/package.json ./packages/cli/package.json +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store pnpm --filter @immich/cli install --frozen-lockfile + +COPY ./packages/cli ./packages/cli/ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ + pnpm --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned -FROM builder AS plugins +FROM plugin-sdk AS plugins ARG TARGETPLATFORM -COPY --from=ghcr.io/jdx/mise:2026.8.3@sha256:92dbc3f2573926d8974e4641ad8449f16c323130b9f41c39aff19b7b2f500ef6 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.8.8@sha256:638270210fb690efc0a8492b2c173d51bee6cb33f26381f05790d90c8e646a80 /usr/local/bin/mise /usr/local/bin/mise -WORKDIR /app COPY ./mise.toml ./mise.toml COPY ./mise.lock ./mise.lock COPY ./packages/plugin-core/mise.toml ./packages/plugin-core/ -ENV MISE_TRUSTED_CONFIG_PATHS=/app/mise.toml +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/mise.toml ENV MISE_DATA_DIR=/buildcache/mise ENV MISE_DISABLE_TOOLS=flutter RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise install --locked -COPY ./packages/sdk ./packages/sdk/ COPY ./packages/plugin-core ./packages/plugin-core/ -COPY ./packages/plugin-sdk ./packages/plugin-sdk/ # Build plugins -RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \ - --mount=type=bind,source=package.json,target=package.json \ - --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ - --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ - --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ +RUN --mount=type=cache,id=pnpm,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise //:plugins -FROM ghcr.io/immich-app/base-server-prod:202607211135@sha256:ced131da7523544fe975cfd25abd67386e712e39496340b437cd95a08d0a18f3 +FROM ghcr.io/immich-app/base-server-prod:202608111116@sha256:15083a121ada9d243c3e717d84c2819a028f214c1f4bf3b39d0b63a299e994b3 WORKDIR /usr/src/app ENV NODE_ENV=production \ @@ -91,8 +96,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli -COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist -COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json +COPY --from=plugins /usr/src/app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist +COPY --from=plugins /usr/src/app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 9a46f41b4e..a390e8afb9 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,8 +1,8 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202607211135@sha256:83c9ff3f7390111596a2dcd24a746c3f8618d58259dbc7aae36618d153462f18 AS dev +FROM ghcr.io/immich-app/base-server-dev:202608111116@sha256:f53e6bc2aa6e4c7b043e6ec1740448af4ba69e2090411daa7fa1df288bc0131a AS dev -COPY --from=ghcr.io/jdx/mise:2026.8.3@sha256:92dbc3f2573926d8974e4641ad8449f16c323130b9f41c39aff19b7b2f500ef6 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.8.8@sha256:638270210fb690efc0a8492b2c173d51bee6cb33f26381f05790d90c8e646a80 /usr/local/bin/mise /usr/local/bin/mise RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 2c11b7d3cb..241d8fd532 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -2,13 +2,8 @@ import js from '@eslint/js'; import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import eslintPluginUnicorn from 'eslint-plugin-unicorn'; import globals from 'globals'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import typescriptEslint from 'typescript-eslint'; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - export default typescriptEslint.config([ eslintPluginUnicorn.configs.recommended, eslintPluginPrettierRecommended, @@ -29,7 +24,7 @@ export default typescriptEslint.config([ parserOptions: { project: 'tsconfig.json', - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, @@ -56,6 +51,7 @@ export default typescriptEslint.config([ 'unicorn/max-nested-calls': 'off', 'unicorn/no-declarations-before-early-exit': 'off', 'unicorn/no-unreadable-object-destructuring': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], // maybe we do want to enable this later. TBD 'unicorn/prefer-await': 'off', 'unicorn/consistent-class-member-order': 'off', diff --git a/server/package.json b/server/package.json index 7d5f73cc61..facce43aa4 100644 --- a/server/package.json +++ b/server/package.json @@ -107,7 +107,7 @@ "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "semver": "^7.8.1", - "sharp": "^0.34.5", + "sharp": "^0.35.3", "sirv": "^3.0.0", "socket.io": "^4.8.1", "tailwindcss-preset-email": "^1.4.0", @@ -151,7 +151,7 @@ "eslint": "^10.0.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.1.3", - "eslint-plugin-unicorn": "^72.0.0", + "eslint-plugin-unicorn": "^73.0.0", "globals": "^17.0.0", "mock-fs": "^5.2.0", "pngjs": "^7.0.0", @@ -169,6 +169,6 @@ "vitest": "^3.0.0" }, "overrides": { - "sharp": "^0.34.5" + "sharp": "^0.35.3" } } diff --git a/server/src/config.ts b/server/src/config.ts deleted file mode 100644 index 55304080a3..0000000000 --- a/server/src/config.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { CronExpression } from '@nestjs/schedule'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; -import { - AudioCodec, - Colorspace, - CQMode, - HlsVideoResolution, - ImageFormat, - LogLevel, - OAuthTokenEndpointAuthMethod, - QueueName, - ToneMapping, - TranscodeHardwareAcceleration, - TranscodePolicy, - VideoCodec, - VideoContainer, -} from 'src/enum'; -import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types'; - -export type SystemConfig = { - backup: { - database: { - enabled: boolean; - cronExpression: string; - keepLastAmount: number; - }; - }; - ffmpeg: { - crf: number; - threads: number; - preset: string; - targetVideoCodec: VideoCodec; - acceptedVideoCodecs: VideoCodec[]; - targetAudioCodec: AudioCodec; - acceptedAudioCodecs: AudioCodec[]; - acceptedContainers: VideoContainer[]; - targetResolution: string; - maxBitrate: string; - bframes: number; - refs: number; - gopSize: number; - temporalAQ: boolean; - cqMode: CQMode; - twoPass: boolean; - preferredHwDevice: string; - transcode: TranscodePolicy; - accel: TranscodeHardwareAcceleration; - accelDecode: boolean; - tonemap: ToneMapping; - realtime: { - enabled: boolean; - videoCodecs: VideoCodec[]; - resolutions: HlsVideoResolution[]; - }; - }; - integrityChecks: { - missingFiles: { - enabled: boolean; - cronExpression: string; - }; - untrackedFiles: { - enabled: boolean; - cronExpression: string; - }; - checksumFiles: { - enabled: boolean; - cronExpression: string; - timeLimit: number; - percentageLimit: number; - }; - }; - job: Record; - logging: { - enabled: boolean; - level: LogLevel; - }; - machineLearning: { - enabled: boolean; - urls: string[]; - availabilityChecks: { - enabled: boolean; - timeout: number; - interval: number; - }; - clip: { - enabled: boolean; - modelName: string; - }; - duplicateDetection: { - enabled: boolean; - maxDistance: number; - }; - facialRecognition: { - enabled: boolean; - modelName: string; - minScore: number; - minFaces: number; - maxDistance: number; - }; - ocr: { - enabled: boolean; - modelName: string; - minDetectionScore: number; - minRecognitionScore: number; - maxResolution: number; - }; - }; - map: { - enabled: boolean; - lightStyle: string; - darkStyle: string; - }; - reverseGeocoding: { - enabled: boolean; - }; - metadata: { - faces: { - import: boolean; - }; - }; - oauth: { - autoLaunch: boolean; - autoRegister: boolean; - buttonText: string; - clientId: string; - clientSecret: string; - defaultStorageQuota: number | null; - enabled: boolean; - issuerUrl: string; - endSessionEndpoint: string; - mobileOverrideEnabled: boolean; - mobileRedirectUri: string; - prompt: string; - scope: string; - signingAlgorithm: string; - profileSigningAlgorithm: string; - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; - timeout: number; - allowInsecureRequests: boolean; - storageLabelClaim: string; - storageQuotaClaim: string; - roleClaim: string; - }; - passwordLogin: { - enabled: boolean; - }; - storageTemplate: { - enabled: boolean; - hashVerificationEnabled: boolean; - template: string; - }; - image: { - thumbnail: ImageOptions; - preview: ImageOptions; - colorspace: Colorspace; - extractEmbedded: boolean; - fullsize: FullsizeImageOptions; - }; - newVersionCheck: { - enabled: boolean; - channel: ReleaseChannel; - }; - nightlyTasks: { - startTime: string; - databaseCleanup: boolean; - missingThumbnails: boolean; - clusterNewFaces: boolean; - generateMemories: boolean; - syncQuotaUsage: boolean; - }; - trash: { - enabled: boolean; - days: number; - }; - theme: { - customCss: string; - }; - library: { - scan: { - enabled: boolean; - cronExpression: string; - }; - watch: { - enabled: boolean; - }; - }; - notifications: { - smtp: { - enabled: boolean; - from: string; - replyTo: string; - transport: { - ignoreCert: boolean; - host: string; - port: number; - secure: boolean; - username: string; - password: string; - }; - }; - }; - templates: { - email: { - welcomeTemplate: string; - albumInviteTemplate: string; - albumUpdateTemplate: string; - }; - }; - server: { - externalDomain: string; - loginPageMessage: string; - publicUsers: boolean; - }; - user: { - deleteDelay: number; - }; -}; - -export type MachineLearningConfig = SystemConfig['machineLearning']; - -export const defaults = Object.freeze({ - backup: { - database: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_2AM, - keepLastAmount: 14, - }, - }, - ffmpeg: { - crf: 23, - threads: 0, - preset: 'ultrafast', - targetVideoCodec: VideoCodec.H264, - acceptedVideoCodecs: [VideoCodec.H264], - targetAudioCodec: AudioCodec.Aac, - acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], - acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], - targetResolution: '720', - maxBitrate: '0', - bframes: -1, - refs: 0, - gopSize: 0, - temporalAQ: false, - cqMode: CQMode.Auto, - twoPass: false, - preferredHwDevice: 'auto', - transcode: TranscodePolicy.Required, - tonemap: ToneMapping.Hable, - accel: TranscodeHardwareAcceleration.Disabled, - accelDecode: true, - realtime: { - enabled: false, - videoCodecs: [VideoCodec.H264, VideoCodec.Hevc], - resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080], - }, - }, - integrityChecks: { - missingFiles: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_3AM, - }, - untrackedFiles: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_3AM, - }, - checksumFiles: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_3AM, - timeLimit: 60 * 60 * 1000, // 1 hour - percentageLimit: 1, // 100% of assets - }, - }, - job: { - [QueueName.BackgroundTask]: { concurrency: 5 }, - [QueueName.SmartSearch]: { concurrency: 2 }, - [QueueName.MetadataExtraction]: { concurrency: 5 }, - [QueueName.FaceDetection]: { concurrency: 2 }, - [QueueName.Search]: { concurrency: 5 }, - [QueueName.Sidecar]: { concurrency: 5 }, - [QueueName.Library]: { concurrency: 5 }, - [QueueName.Migration]: { concurrency: 5 }, - [QueueName.ThumbnailGeneration]: { concurrency: 3 }, - [QueueName.VideoConversion]: { concurrency: 1 }, - [QueueName.Notification]: { concurrency: 5 }, - [QueueName.Ocr]: { concurrency: 1 }, - [QueueName.Workflow]: { concurrency: 5 }, - [QueueName.IntegrityCheck]: { concurrency: 1 }, - [QueueName.Editor]: { concurrency: 2 }, - }, - logging: { - enabled: true, - level: LogLevel.Log, - }, - machineLearning: { - enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', - urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], - availabilityChecks: { - enabled: true, - timeout: 2000, - interval: 30_000, - }, - clip: { - enabled: true, - modelName: 'ViT-B-32__openai', - }, - duplicateDetection: { - enabled: true, - maxDistance: 0.01, - }, - facialRecognition: { - enabled: true, - modelName: 'buffalo_l', - minScore: 0.7, - maxDistance: 0.5, - minFaces: 3, - }, - ocr: { - enabled: true, - modelName: 'PP-OCRv5_mobile', - minDetectionScore: 0.5, - minRecognitionScore: 0.8, - maxResolution: 736, - }, - }, - map: { - enabled: true, - lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', - darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', - }, - reverseGeocoding: { - enabled: true, - }, - metadata: { - faces: { - import: false, - }, - }, - oauth: { - autoLaunch: false, - autoRegister: true, - buttonText: 'Login with OAuth', - clientId: '', - clientSecret: '', - defaultStorageQuota: null, - enabled: false, - issuerUrl: '', - endSessionEndpoint: '', - mobileOverrideEnabled: false, - mobileRedirectUri: '', - prompt: '', - scope: 'openid email profile', - signingAlgorithm: 'RS256', - profileSigningAlgorithm: 'none', - storageLabelClaim: 'preferred_username', - storageQuotaClaim: 'immich_quota', - roleClaim: 'immich_role', - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost, - timeout: 30_000, - allowInsecureRequests: false, - }, - passwordLogin: { - enabled: true, - }, - storageTemplate: { - enabled: false, - hashVerificationEnabled: true, - template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', - }, - image: { - thumbnail: { - format: ImageFormat.Webp, - size: 250, - quality: 80, - progressive: false, - }, - preview: { - format: ImageFormat.Jpeg, - size: 1440, - quality: 80, - progressive: false, - }, - colorspace: Colorspace.P3, - extractEmbedded: false, - fullsize: { - enabled: false, - format: ImageFormat.Jpeg, - quality: 80, - progressive: false, - }, - }, - newVersionCheck: { - enabled: true, - channel: ReleaseChannel.Stable, - }, - nightlyTasks: { - startTime: '00:00', - databaseCleanup: true, - generateMemories: true, - syncQuotaUsage: true, - missingThumbnails: true, - clusterNewFaces: true, - }, - trash: { - enabled: true, - days: 30, - }, - theme: { - customCss: '', - }, - library: { - scan: { - enabled: true, - cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT, - }, - watch: { - enabled: false, - }, - }, - server: { - externalDomain: '', - loginPageMessage: '', - publicUsers: true, - }, - notifications: { - smtp: { - enabled: false, - from: '', - replyTo: '', - transport: { - ignoreCert: false, - host: '', - port: 587, - secure: false, - username: '', - password: '', - }, - }, - }, - templates: { - email: { - welcomeTemplate: '', - albumInviteTemplate: '', - albumUpdateTemplate: '', - }, - }, - user: { - deleteDelay: 7, - }, -}); diff --git a/server/src/constants.ts b/server/src/constants.ts index 815a400ed8..856a6ffc57 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -148,8 +148,14 @@ export const endpointTags: Record = { [ApiTag.Albums]: 'An album is a collection of assets that can be shared with other users or via shared links.', [ApiTag.ApiKeys]: 'An api key can be used to programmatically access the Immich API.', [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', + [ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', + [ApiTag.ClusterGroups]: + 'A cluster group is a set of users whose faces are clustered together, so that a person can be shared between them.', + [ApiTag.ConfigUser]: 'The system configuration properties that are visible to logged in users.', + [ApiTag.ConfigAdmin]: 'Endpoints to view and modify the full system configuration.', + [ApiTag.ConfigPublic]: 'The system configuration properties that are visible to everyone.', [ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.', [ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.', [ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.', diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index 08963362b6..815e00a9a5 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -44,6 +44,14 @@ describe(ApiKeyController.name, () => { }); }); + describe('POST /api-keys/:id/rotate', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post(`/api-keys/123/rotate`); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + }); + describe('DELETE /api-keys/:id', () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()).delete(`/api-keys/123`); diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index b7e3627faa..a179e253de 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -87,6 +87,18 @@ export class ApiKeyController { return this.service.update(auth, id, dto); } + @Post(':id/rotate') + @Authenticated({ permission: Permission.ApiKeyRotate }) + @Endpoint({ + summary: 'Rotate an API key', + description: + 'Generates a new secret for an API key, immediately invalidating the previous one. The current user must own this API key.', + history: new HistoryBuilder().added('v3'), + }) + rotateApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.rotate(auth, id); + } + @Delete(':id') @Authenticated({ permission: Permission.ApiKeyDelete }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/server/src/controllers/asset-file.controller.ts b/server/src/controllers/asset-file.controller.ts new file mode 100644 index 0000000000..5d50f54eea --- /dev/null +++ b/server/src/controllers/asset-file.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Delete, Get, HttpCode, HttpStatus, Next, Param, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AssetFileResponseDto, AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetFileService } from 'src/services/asset-file.service'; +import { sendFile } from 'src/utils/file'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags(ApiTag.AssetFiles) +@Controller('asset-files') +export class AssetFilesController { + constructor( + private service: AssetFileService, + private logger: LoggingRepository, + ) {} + + @Get() + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Search asset files', + description: 'Returns all matching asset files.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + searchAssetFiles(@Auth() auth: AuthDto, @Query() dto: AssetFileSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Retrieve an asset file', + description: 'Returns metadata about a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Delete(':id') + @Authenticated({ permission: Permission.AssetFileDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an asset file', + description: 'Delete a file and remove it from the database.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + deleteAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } + + @Get(':id/download') + @FileResponse() + @Authenticated({ permission: Permission.AssetFileDownload }) + @Endpoint({ + summary: 'Download an asset file', + description: 'Serve the contents of a specific asset file.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + async downloadAssetFile( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.download(auth, id), this.logger); + } +} diff --git a/server/src/controllers/cluster-group.controller.ts b/server/src/controllers/cluster-group.controller.ts new file mode 100644 index 0000000000..58d7fa5d08 --- /dev/null +++ b/server/src/controllers/cluster-group.controller.ts @@ -0,0 +1,107 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ClusterGroupRequestCreateDto, ClusterGroupRequestResponseDto } from 'src/dtos/cluster-group.dto'; +import { UserResponseDto } from 'src/dtos/user.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { ClusterGroupService } from 'src/services/cluster-group.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags(ApiTag.ClusterGroups) +@Controller('cluster-groups') +export class ClusterGroupController { + constructor(private service: ClusterGroupService) {} + + @Get('requests') + @Authenticated({ permission: Permission.ClusterGroupRequestRead }) + @Endpoint({ + summary: 'Retrieve cluster group requests', + description: 'Retrieve the pending requests for the current user to join a cluster group.', + history: new HistoryBuilder().added('v3.2.0'), + }) + getClusterGroupRequests(@Auth() auth: AuthDto): Promise { + return this.service.getRequests(auth); + } + + @Post('requests/:id/accept') + @Authenticated({ permission: Permission.ClusterGroupRequestCreate }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Accept a cluster group request', + description: 'Join the cluster group the request was created for.', + history: new HistoryBuilder().added('v3.2.0'), + }) + acceptClusterGroupRequest(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.acceptRequest(auth, id); + } + + @Delete('requests/:id') + @Authenticated({ permission: Permission.ClusterGroupRequestDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Decline a cluster group request', + description: 'Delete a pending request to join a cluster group.', + history: new HistoryBuilder().added('v3.2.0'), + }) + deleteClusterGroupRequest(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.deleteRequest(auth, id); + } + + @Get(':id/requests') + @Authenticated({ permission: Permission.ClusterGroupRequestRead }) + @Endpoint({ + summary: 'Retrieve the requests sent by a cluster group', + description: 'Retrieve the pending requests for other users to join the cluster group.', + history: new HistoryBuilder().added('v3.2.0'), + }) + getClusterGroupRequestsForGroup( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.getRequestsForGroup(auth, id); + } + + @Get(':id/users') + @Authenticated({ permission: Permission.ClusterGroupRead }) + @Endpoint({ + summary: 'Retrieve the users of a cluster group', + description: 'Retrieve the users that are a member of the cluster group.', + history: new HistoryBuilder().added('v3.2.0'), + }) + getClusterGroupUsers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getUsers(auth, id); + } + + @Put(':id/requests') + @Authenticated({ permission: Permission.ClusterGroupRequestCreate }) + @Endpoint({ + summary: 'Create a cluster group request', + description: 'Ask another user to join the cluster group of the current user.', + history: new HistoryBuilder().added('v3.2.0'), + }) + async createClusterGroupRequest( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: ClusterGroupRequestCreateDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { duplicate, value } = await this.service.createRequest(auth, id, dto); + res.status(duplicate ? HttpStatus.OK : HttpStatus.CREATED); + return value; + } + + @Post(':id/leave') + @Authenticated({ permission: Permission.ClusterGroupLeave }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Leave a cluster group', + description: 'Move the current user into a new cluster group of their own.', + history: new HistoryBuilder().added('v3.2.0'), + }) + leaveClusterGroup(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.leave(auth, id); + } +} diff --git a/server/src/controllers/config-admin.controller.ts b/server/src/controllers/config-admin.controller.ts new file mode 100644 index 0000000000..b135b0e62e --- /dev/null +++ b/server/src/controllers/config-admin.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AdminConfigDto } from 'src/dtos/config.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemConfigService } from 'src/services/system-config.service'; + +@ApiTags(ApiTag.ConfigAdmin) +@Controller('admin/config') +export class ConfigAdminController { + constructor(private service: SystemConfigService) {} + + @Get() + @Authenticated({ permission: Permission.AdminConfigRead, admin: true }) + @Endpoint({ + summary: 'Get the admin configuration', + description: 'Retrieve admin configuration.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAdminConfig(): Promise { + return this.service.getAdminConfig(); + } + + @Get('defaults') + @Authenticated({ permission: Permission.AdminConfigRead, admin: true }) + @Endpoint({ + summary: 'Get the system configuration defaults', + description: 'Retrieve the default value of every system configuration property.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getAdminConfigDefaults(): AdminConfigDto { + return this.service.getAdminConfigDefaults(); + } + + @Put() + @Authenticated({ permission: Permission.AdminConfigUpdate, admin: true }) + @Endpoint({ + summary: 'Update the system configuration', + description: 'Update the system configuration with a new system configuration.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + updateAdminConfig(@Body() dto: AdminConfigDto): Promise { + return this.service.updateAdminConfig(dto); + } +} diff --git a/server/src/controllers/config-public.controller.ts b/server/src/controllers/config-public.controller.ts new file mode 100644 index 0000000000..87f7bb14c8 --- /dev/null +++ b/server/src/controllers/config-public.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { PublicConfigDto } from 'src/dtos/config.dto'; +import { ApiTag } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemConfigService } from 'src/services/system-config.service'; + +@ApiTags(ApiTag.ConfigPublic) +@Controller('public/config') +export class ConfigPublicController { + constructor(private service: SystemConfigService) {} + + @Get() + @Authenticated({ public: true }) + @Endpoint({ + summary: 'Get the public configuration', + description: 'Retrieve the system configuration properties that are visible to everyone.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getPublicConfig(): Promise { + return this.service.getPublicConfig(); + } + + @Get('defaults') + @Authenticated({ public: true }) + @Endpoint({ + summary: 'Get the public configuration defaults', + description: 'Retrieve the default value of the configuration properties that are visible to everyone.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getPublicConfigDefaults(): PublicConfigDto { + return this.service.getPublicConfigDefaults(); + } +} diff --git a/server/src/controllers/config-user.controller.ts b/server/src/controllers/config-user.controller.ts new file mode 100644 index 0000000000..8f49d119fd --- /dev/null +++ b/server/src/controllers/config-user.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { UserConfigDto } from 'src/dtos/config.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { SystemConfigService } from 'src/services/system-config.service'; + +@ApiTags(ApiTag.ConfigUser) +@Controller('config') +export class ConfigUserController { + constructor(private service: SystemConfigService) {} + + @Get() + @Authenticated({ permission: Permission.UserConfigRead }) + @Endpoint({ + summary: 'Get the configuration with user visibility', + description: 'Retrieve the system configuration properties that are visible to logged in users.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getUserConfig(): Promise { + return this.service.getUserConfig(); + } + + @Get('defaults') + @Authenticated({ permission: Permission.UserConfigRead }) + @Endpoint({ + summary: 'Get the default configuration with user visibility', + description: 'Retrieve the default value of the configuration properties that are visible to logged in users.', + history: new HistoryBuilder().added('v3.2.0').alpha('v3.2.0'), + }) + getUserConfigDefaults(): UserConfigDto { + return this.service.getUserConfigDefaults(); + } +} diff --git a/server/src/controllers/config.controller.spec.ts b/server/src/controllers/config.controller.spec.ts new file mode 100644 index 0000000000..fdd6a76e74 --- /dev/null +++ b/server/src/controllers/config.controller.spec.ts @@ -0,0 +1,116 @@ +import _ from 'lodash'; +import { ConfigAdminController } from 'src/controllers/config-admin.controller'; +import { ConfigPublicController } from 'src/controllers/config-public.controller'; +import { ConfigUserController } from 'src/controllers/config-user.controller'; +import { defaults, mapPublicConfig, mapUserConfig } from 'src/dtos/config.dto'; +import { SystemConfigService } from 'src/services/system-config.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +/** Returns a full config that passes Zod validation (required URLs and min lengths). */ +function validConfig() { + const config = _.cloneDeep(defaults) as typeof defaults & { + oauth: { mobileRedirectUri: string }; + notifications: { smtp: { from: string; transport: { host: string } } }; + server: { externalDomain: string }; + }; + config.oauth.mobileRedirectUri ||= 'https://example.com'; + config.server.externalDomain ||= 'https://example.com'; + config.notifications.smtp.from ||= 'noreply@example.com'; + config.notifications.smtp.transport.host ||= 'localhost'; + return config; +} + +describe('config controllers', () => { + let ctx: ControllerContext; + const service = mockBaseService(SystemConfigService); + + beforeAll(async () => { + ctx = await controllerSetup( + [ConfigAdminController, ConfigUserController, ConfigPublicController], + [{ provide: SystemConfigService, useValue: service }], + ); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /admin/config', () => { + it('should return the full config', async () => { + service.getAdminConfig.mockResolvedValue(validConfig()); + + const { status, body } = await request(ctx.getHttpServer()).get('/admin/config'); + + expect(status).toBe(200); + expect(body.oauth.clientSecret).toBeDefined(); + expect(body.job.thumbnailGeneration).toBeDefined(); + }); + }); + + describe('PUT /admin/config', () => { + it('should accept a valid config', async () => { + service.updateAdminConfig.mockImplementation((dto) => Promise.resolve(dto)); + + const { status } = await request(ctx.getHttpServer()).put('/admin/config').send(validConfig()); + + expect(status).toBe(200); + }); + + it('should reject an invalid config', async () => { + const config = validConfig(); + config.nightlyTasks.startTime = 'invalid'; + + const { status, body } = await request(ctx.getHttpServer()).put('/admin/config').send(config); + + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { + path: ['nightlyTasks', 'startTime'], + message: 'Invalid input: expected string in HH:MM format, received string', + }, + ]), + ); + expect(service.updateAdminConfig).not.toHaveBeenCalled(); + }); + }); + + describe('GET /config', () => { + it('should return the properties visible to logged in users', async () => { + service.getUserConfig.mockResolvedValue(mapUserConfig(validConfig())); + + const { status, body } = await request(ctx.getHttpServer()).get('/config'); + + expect(status).toBe(200); + expect(body.image).toEqual({ + thumbnail: { size: defaults.image.thumbnail.size }, + preview: { size: defaults.image.preview.size }, + fullsize: { enabled: defaults.image.fullsize.enabled }, + }); + expect(body.oauth.clientSecret).toBeUndefined(); + expect(body.job).toBeUndefined(); + }); + }); + + describe('GET /public/config', () => { + it('should return the properties visible to everyone', async () => { + service.getPublicConfig.mockResolvedValue(mapPublicConfig(validConfig())); + + const { status, body } = await request(ctx.getHttpServer()).get('/public/config'); + + expect(status).toBe(200); + expect(body.server).toEqual({ loginPageMessage: defaults.server.loginPageMessage }); + expect(body.oauth).toEqual({ + autoLaunch: defaults.oauth.autoLaunch, + buttonText: defaults.oauth.buttonText, + enabled: defaults.oauth.enabled, + }); + expect(body.image).toBeUndefined(); + expect(body.trash).toBeUndefined(); + }); + }); +}); diff --git a/server/src/controllers/download.controller.spec.ts b/server/src/controllers/download.controller.spec.ts new file mode 100644 index 0000000000..ce3768895b --- /dev/null +++ b/server/src/controllers/download.controller.spec.ts @@ -0,0 +1,48 @@ +import { Readable } from 'node:stream'; +import { DownloadController } from 'src/controllers/download.controller'; +import { DownloadService } from 'src/services/download.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(DownloadController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(DownloadService); + + beforeAll(async () => { + ctx = await controllerSetup(DownloadController, [{ provide: DownloadService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /download/archive', () => { + it('should accept comma-separated assetIds string', async () => { + const downloadArchiveSpy = vi.spyOn(service, 'downloadArchive'); + service.downloadArchive.mockResolvedValue({ stream: Readable.from('') }); + + const ids = [factory.uuid(), factory.uuid()]; + const { status } = await request(ctx.getHttpServer()) + .post(`/download/archive`) + .type('form') + .send({ assetIds: ids.join(',') }); + expect(status).toBe(200); + expect(downloadArchiveSpy).toHaveBeenCalledWith(undefined, { assetIds: ids }); + }); + + it('should accept assetIds array', async () => { + const downloadArchiveSpy = vi.spyOn(service, 'downloadArchive'); + service.downloadArchive.mockResolvedValue({ stream: Readable.from('') }); + + const ids = [factory.uuid(), factory.uuid()]; + const { status } = await request(ctx.getHttpServer()).post(`/download/archive`).send({ + assetIds: ids, + }); + expect(status).toBe(200); + expect(downloadArchiveSpy).toHaveBeenCalledWith(undefined, { assetIds: ids }); + }); + }); +}); diff --git a/server/src/controllers/index.spec.ts b/server/src/controllers/index.spec.ts index 3d39a4dd3d..69a3f1b0c8 100644 --- a/server/src/controllers/index.spec.ts +++ b/server/src/controllers/index.spec.ts @@ -10,6 +10,61 @@ const UNAUTHENTICATED_ADMIN_ROUTES = new Set([ 'POST admin/database-backups/start-restore', ]); +/** Admin-only routes that live outside `admin/`, i.e. `@Authenticated({ admin: true })` */ +const ADMIN_ROUTES = new Set([ + 'DELETE libraries/:id', + 'DELETE queues/:name/jobs', + 'DELETE server/license', + 'GET jobs', + 'GET libraries', + 'GET libraries/:id', + 'GET libraries/:id/statistics', + 'GET queues', + 'GET queues/:name', + 'GET queues/:name/jobs', + 'GET server/license', + 'GET server/statistics', + 'GET system-config', + 'GET system-config/defaults', + 'GET system-config/storage-template-options', + 'GET system-metadata/admin-onboarding', + 'GET system-metadata/reverse-geocoding-state', + 'GET system-metadata/version-check-state', + 'PATCH libraries/:id', + 'POST jobs', + 'POST libraries', + 'POST libraries/:id/scan', + 'POST libraries/:id/validate', + 'POST system-metadata/admin-onboarding', + 'PUT jobs/:name', + 'PUT libraries/:id', + 'PUT queues/:name', + 'PUT server/license', + 'PUT system-config', +]); + +/** Routes a shared link (`?key=`) is allowed to reach, i.e. `@Authenticated({ sharedLink: true })` */ +const SHARED_LINK_ROUTES = new Set([ + 'DELETE assets/:id/video/stream/:sessionId', + 'GET albums/:id', + 'GET albums/:id/map-markers', + 'GET assets/:id', + 'GET assets/:id/original', + 'GET assets/:id/thumbnail', + 'GET assets/:id/video/playback', + 'GET assets/:id/video/stream/:sessionId/:variantIndex/:filename', + 'GET assets/:id/video/stream/:sessionId/:variantIndex/playlist.m3u8', + 'GET assets/:id/video/stream/main.m3u8', + 'GET shared-links/me', + 'GET timeline/bucket', + 'GET timeline/buckets', + 'POST assets', + 'POST download/archive', + 'POST download/info', + 'POST search/metadata', + 'POST shared-links/login', +]); + const isAdminPermission = (permission: AuthenticatedOptions['permission']) => typeof permission === 'string' && permission.startsWith('admin'); @@ -45,9 +100,9 @@ const getRoutes = () => { describe('controllers', () => { const routes = getRoutes(); - const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/')); it('should only allow non-admin access to bootstrap routes under admin/', () => { + const adminRoutes = routes.filter((route) => route.path === 'admin' || route.path.startsWith('admin/')); const reachableByNonAdmins = adminRoutes.filter((route) => !route.auth?.admin).map((route) => route.id); expect(new Set(reachableByNonAdmins)).toEqual(UNAUTHENTICATED_ADMIN_ROUTES); @@ -59,6 +114,20 @@ describe('controllers', () => { expect(undeclared).toEqual([]); }); + it('should only allow shared link access to expected routes', () => { + const sharedLinkRoutes = routes.filter((route) => route.auth?.sharedLink).map((route) => route.id); + + expect(new Set(sharedLinkRoutes)).toEqual(SHARED_LINK_ROUTES); + }); + + it('should only require admin access on expected routes outside /admin', () => { + const adminRoutes = routes + .filter((route) => route.auth?.admin && route.path !== 'admin' && !route.path.startsWith('admin/')) + .map((route) => route.id); + + expect(new Set(adminRoutes)).toEqual(ADMIN_ROUTES); + }); + it('should require admin access for routes with an admin permission', () => { const offenders = routes .filter((route) => isAdminPermission(route.auth?.permission) && !route.auth?.admin) diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e7a01643ab..a50ab050e5 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -2,10 +2,15 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; +import { AssetFilesController } from 'src/controllers/asset-file.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller'; import { AuthController } from 'src/controllers/auth.controller'; +import { ClusterGroupController } from 'src/controllers/cluster-group.controller'; +import { ConfigAdminController } from 'src/controllers/config-admin.controller'; +import { ConfigPublicController } from 'src/controllers/config-public.controller'; +import { ConfigUserController } from 'src/controllers/config-user.controller'; import { DatabaseBackupController } from 'src/controllers/database-backup.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; @@ -46,9 +51,14 @@ export const controllers = [ AlbumController, AppController, AssetController, + AssetFilesController, AssetMediaController, AuthController, AuthAdminController, + ClusterGroupController, + ConfigUserController, + ConfigAdminController, + ConfigPublicController, DatabaseBackupController, DownloadController, DuplicateController, diff --git a/server/src/controllers/job.controller.spec.ts b/server/src/controllers/job.controller.spec.ts new file mode 100644 index 0000000000..4f6f9337c8 --- /dev/null +++ b/server/src/controllers/job.controller.spec.ts @@ -0,0 +1,45 @@ +import { JobController } from 'src/controllers/job.controller'; +import { JobService } from 'src/services/job.service'; +import { QueueService } from 'src/services/queue.service'; +import request from 'supertest'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(JobController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(JobService); + const queueService = mockBaseService(QueueService); + + beforeAll(async () => { + ctx = await controllerSetup(JobController, [ + { provide: JobService, useValue: service }, + { provide: QueueService, useValue: queueService }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + queueService.resetAllMocks(); + ctx.reset(); + }); + + describe('PUT /jobs/:name', () => { + it('should require a valid queue name', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/jobs/invalid') + .send({ command: 'start', force: false }); + + expect(status).toBe(400); + expect(queueService.runCommandLegacy).not.toHaveBeenCalled(); + }); + + it('should require a valid command', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/jobs/metadataExtraction') + .send({ command: 'invalid', force: false }); + + expect(status).toBe(400); + expect(queueService.runCommandLegacy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/library.controller.spec.ts b/server/src/controllers/library.controller.spec.ts new file mode 100644 index 0000000000..2b606f9835 --- /dev/null +++ b/server/src/controllers/library.controller.spec.ts @@ -0,0 +1,181 @@ +import { LibraryController } from 'src/controllers/library.controller'; +import { LibraryService } from 'src/services/library.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(LibraryController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(LibraryService); + + beforeAll(async () => { + ctx = await controllerSetup(LibraryController, [{ provide: LibraryService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + const id = factory.uuid(); + + describe('POST /libraries', () => { + it('should require an owner id', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['ownerId'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should reject duplicate import paths', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/libraries') + .send({ ownerId: id, importPaths: ['/path', '/path'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should reject duplicate exclusion patterns', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/libraries') + .send({ ownerId: id, exclusionPatterns: ['**/Raw/**', '**/Raw/**'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /libraries/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/libraries/invalid') + .send({ name: 'New Library Name' }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty name', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/libraries/${id}`).send({ name: '' }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['name'], message: 'Too small: expected string to have >=1 characters' }, + ]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty import path', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ importPaths: [''] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array items must not be empty' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject duplicate import paths', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ importPaths: ['/path', '/path'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['importPaths'], message: 'Array must have unique items' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject an empty exclusion pattern', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ exclusionPatterns: [''] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array items must not be empty' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('should reject duplicate exclusion patterns', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/libraries/${id}`) + .send({ exclusionPatterns: ['**/*.jpg', '**/*.jpg'] }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['exclusionPatterns'], message: 'Array must have unique items' }]), + ); + expect(service.update).not.toHaveBeenCalled(); + }); + }); + + describe('POST /libraries/:id/validate', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries/invalid/validate').send({}); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.validate).not.toHaveBeenCalled(); + }); + + it('should not require import paths', async () => { + service.validate.mockResolvedValue({ importPaths: [] }); + + const { status } = await request(ctx.getHttpServer()).post(`/libraries/${id}/validate`).send({}); + + expect(status).toBe(200); + expect(service.validate).toHaveBeenCalledWith(id, {}); + }); + }); + + describe('GET /libraries/:id/statistics', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/libraries/invalid/statistics'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.getStatistics).not.toHaveBeenCalled(); + }); + }); + + describe('POST /libraries/:id/scan', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/libraries/invalid/scan'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.queueScan).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /libraries/:id', () => { + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).delete('/libraries/invalid'); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + expect(service.delete).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/map.controller.spec.ts b/server/src/controllers/map.controller.spec.ts new file mode 100644 index 0000000000..4c5f16bc77 --- /dev/null +++ b/server/src/controllers/map.controller.spec.ts @@ -0,0 +1,66 @@ +import { MapController } from 'src/controllers/map.controller'; +import { MapService } from 'src/services/map.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(MapController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(MapService); + + beforeAll(async () => { + ctx = await controllerSetup(MapController, [{ provide: MapService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /map/reverse-geocode', () => { + it('should require a lat', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lon: 123 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + + it('should require a lat that is a number', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get('/map/reverse-geocode') + .query({ lat: 'abc', lon: 123.456 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lat'], message: 'Invalid input: expected number, received NaN' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + + it('should require a lat that is in range', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get('/map/reverse-geocode') + .query({ lat: 91, lon: 123.456 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lat'], message: 'Too big: expected number to be <=90' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + + it('should require a lon', async () => { + const { status, body } = await request(ctx.getHttpServer()).get('/map/reverse-geocode').query({ lat: 75 }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([{ path: ['lon'], message: 'Invalid input: expected number, received NaN' }]), + ); + expect(service.reverseGeocode).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/notification-admin.controller.ts b/server/src/controllers/notification-admin.controller.ts index c322c5a2b6..7a5585fad4 100644 --- a/server/src/controllers/notification-admin.controller.ts +++ b/server/src/controllers/notification-admin.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/com import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/config.dto'; import { NotificationCreateDto, NotificationDto, @@ -9,7 +10,6 @@ import { TemplateResponseDto, TestEmailResponseDto, } from 'src/dtos/notification.dto'; -import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { ApiTag } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { EmailTemplate } from 'src/repositories/email.repository'; diff --git a/server/src/controllers/oauth.controller.spec.ts b/server/src/controllers/oauth.controller.spec.ts new file mode 100644 index 0000000000..a8aa33a4f7 --- /dev/null +++ b/server/src/controllers/oauth.controller.spec.ts @@ -0,0 +1,71 @@ +import { OAuthController } from 'src/controllers/oauth.controller'; +import { AuthService } from 'src/services/auth.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(OAuthController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(AuthService); + + beforeAll(async () => { + ctx = await controllerSetup(OAuthController, [{ provide: AuthService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /oauth/authorize', () => { + it('should require a redirect uri', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/authorize').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['redirectUri'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + expect(service.authorize).not.toHaveBeenCalled(); + }); + }); + + describe('POST /oauth/callback', () => { + it('should require a url', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['url'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + }); + + it('should not allow an empty url', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/callback').send({ url: '' }); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['url'], message: 'Too small: expected string to have >=1 characters' }, + ]), + ); + }); + }); + + describe('POST /oauth/backchannel-logout', () => { + it('should require a logout token', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/oauth/backchannel-logout').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['logout_token'], message: 'Invalid input: expected string, received undefined' }, + ]), + ); + }); + }); +}); diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 6407155492..ef9f408e8c 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -101,7 +101,11 @@ export class ServerController { @Endpoint({ summary: 'Get features', description: 'Retrieve available features supported by this server.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getPublicConfig' }), }) getServerFeatures(): Promise { return this.service.getFeatures(); @@ -112,7 +116,11 @@ export class ServerController { @Endpoint({ summary: 'Get config', description: 'Retrieve the current server configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getPublicConfig' }), }) getServerConfig(): Promise { return this.service.getSystemConfig(); diff --git a/server/src/controllers/shared-link.controller.spec.ts b/server/src/controllers/shared-link.controller.spec.ts index dc069ad267..856a74e340 100644 --- a/server/src/controllers/shared-link.controller.spec.ts +++ b/server/src/controllers/shared-link.controller.spec.ts @@ -3,7 +3,7 @@ import { Permission, SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import request from 'supertest'; import { errorDto } from 'test/medium/responses'; -import { factory } from 'test/small.factory'; +import { factory, newUuid } from 'test/small.factory'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; describe(SharedLinkController.name, () => { @@ -43,9 +43,40 @@ describe(SharedLinkController.name, () => { it('should allow an null expiresAt', async () => { await request(ctx.getHttpServer()) .post('/shared-links') - .send({ expiresAt: null, type: SharedLinkType.Individual }); + .send({ expiresAt: null, type: SharedLinkType.Individual, assetIds: [newUuid()] }); expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null })); }); + + it('should require an albumId for share type Album', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/shared-links') + .send({ type: SharedLinkType.Album }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: [], message: 'albumId is required for type ALBUM' }])); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should not allow an albumId for share type Individual', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/shared-links') + .send({ type: SharedLinkType.Individual, assetIds: [newUuid()], albumId: newUuid() }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'albumId can only be used with type ALBUM' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); + + it('should not allow assetIds for share type Album', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/shared-links') + .send({ type: SharedLinkType.Album, assetIds: [newUuid()], albumId: newUuid() }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: [], message: 'assetIds can only be used with type INDIVIDUAL' }]), + ); + expect(service.create).not.toHaveBeenCalled(); + }); }); describe('DELETE /shared-links/:id/assets', () => { diff --git a/server/src/controllers/system-config.controller.spec.ts b/server/src/controllers/system-config.controller.spec.ts index 7d40f12583..0ae8741c61 100644 --- a/server/src/controllers/system-config.controller.spec.ts +++ b/server/src/controllers/system-config.controller.spec.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { defaults } from 'src/config'; import { SystemConfigController } from 'src/controllers/system-config.controller'; +import { defaults } from 'src/dtos/config.dto'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { SystemConfigService } from 'src/services/system-config.service'; import request from 'supertest'; diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 6b79b38d98..b7c5f00996 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; +import { AdminConfigDto, ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto'; import { ApiTag, Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { StorageTemplateService } from 'src/services/storage-template.service'; @@ -20,10 +20,14 @@ export class SystemConfigController { @Endpoint({ summary: 'Get system configuration', description: 'Retrieve the current system configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getAdminConfig' }), }) - getConfig(): Promise { - return this.service.getSystemConfig(); + getConfig(): Promise { + return this.service.getAdminConfig(); } @Get('defaults') @@ -31,10 +35,14 @@ export class SystemConfigController { @Endpoint({ summary: 'Get system configuration defaults', description: 'Retrieve the default values for the system configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'getAdminConfigDefaults' }), }) - getConfigDefaults(): SystemConfigDto { - return this.service.getDefaults(); + getConfigDefaults(): AdminConfigDto { + return this.service.getAdminConfigDefaults(); } @Put() @@ -42,10 +50,14 @@ export class SystemConfigController { @Endpoint({ summary: 'Update system configuration', description: 'Update the system configuration with a new system configuration.', - history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), + history: new HistoryBuilder() + .added('v1') + .beta('v1') + .stable('v2') + .deprecated('v3.2.0', { replacementId: 'updateAdminConfig' }), }) - updateConfig(@Body() dto: SystemConfigDto): Promise { - return this.service.updateSystemConfig(dto); + updateConfig(@Body() dto: AdminConfigDto): Promise { + return this.service.updateAdminConfig(dto); } @Get('storage-template-options') @@ -55,7 +67,7 @@ export class SystemConfigController { description: 'Retrieve exemplary storage template options.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { + getStorageTemplateOptions(): ConfigTemplateStorageOptionDto { return this.storageTemplateService.getStorageTemplateOptions(); } } diff --git a/server/src/controllers/system-metadata.controller.spec.ts b/server/src/controllers/system-metadata.controller.spec.ts new file mode 100644 index 0000000000..0f5fba08a9 --- /dev/null +++ b/server/src/controllers/system-metadata.controller.spec.ts @@ -0,0 +1,28 @@ +import { SystemMetadataController } from 'src/controllers/system-metadata.controller'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; +import request from 'supertest'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(SystemMetadataController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(SystemMetadataService); + + beforeAll(async () => { + ctx = await controllerSetup(SystemMetadataController, [{ provide: SystemMetadataService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /system-metadata/admin-onboarding', () => { + it('should require isOnboarded', async () => { + const { status } = await request(ctx.getHttpServer()).post('/system-metadata/admin-onboarding').send({}); + + expect(status).toBe(400); + expect(service.updateAdminOnboarding).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/trash.controller.spec.ts b/server/src/controllers/trash.controller.spec.ts new file mode 100644 index 0000000000..1b923692b1 --- /dev/null +++ b/server/src/controllers/trash.controller.spec.ts @@ -0,0 +1,44 @@ +import { TrashController } from 'src/controllers/trash.controller'; +import { TrashService } from 'src/services/trash.service'; +import request from 'supertest'; +import { factory } from 'test/small.factory'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(TrashController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(TrashService); + + beforeAll(async () => { + ctx = await controllerSetup(TrashController, [{ provide: TrashService, useValue: service }]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /trash/restore/assets', () => { + it('should require asset ids', async () => { + const { status, body } = await request(ctx.getHttpServer()).post('/trash/restore/assets').send({}); + + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.validationError([ + { path: ['ids'], message: 'Invalid input: expected array, received undefined' }, + ]), + ); + expect(service.restoreAssets).not.toHaveBeenCalled(); + }); + + it('should require valid asset ids', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post('/trash/restore/assets') + .send({ ids: ['invalid'] }); + + expect(status).toBe(400); + expect(body).toEqual(factory.responses.validationError([{ path: ['ids', 0], message: 'Invalid UUID' }])); + expect(service.restoreAssets).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts index b8d9fd2b93..b0d025763e 100644 --- a/server/src/controllers/workflow.controller.ts +++ b/server/src/controllers/workflow.controller.ts @@ -4,6 +4,8 @@ import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { WorkflowCreateDto, + WorkflowGetLogsDto, + WorkflowLogEntryDto, WorkflowResponseDto, WorkflowSearchDto, WorkflowShareResponseDto, @@ -113,4 +115,19 @@ export class WorkflowController { deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } + + @Get(':id/logs') + @Authenticated({ permission: Permission.WorkflowLogs }) + @Endpoint({ + summary: 'Retrieve workflow logs', + description: 'Retrieve logs of a workflows runs by ID', + history: HistoryBuilder.v3(), + }) + getWorkflowLogs( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() dto: WorkflowGetLogsDto, + ): Promise { + return this.service.getLogs(auth, id, dto); + } } diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 185ab95e62..46124d96aa 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { StorageAsset } from 'src/database'; import { @@ -25,6 +24,8 @@ import { getConfig } from 'src/utils/config'; export interface MoveRequest { entityId: string; + /** a person is owned, so the owner is needed to save the new path */ + ownerId?: string; pathType: PathType; oldPath: string | null; newPath: string; @@ -36,6 +37,8 @@ export interface MoveRequest { export type ThumbnailPathEntity = { id: string; ownerId: string }; +export type PersonThumbnailPathEntity = { personGroupId: string; ownerId: string }; + export type HlsSessionFolder = { ownerId: string; sessionId: string }; export type HlsVariantFolder = { ownerId: string; sessionId: string; variantIndex: number }; @@ -114,8 +117,8 @@ export class StorageCore { return join(StorageCore.getMediaLocation(), folder); } - static getPersonThumbnailPath(person: ThumbnailPathEntity) { - return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`); + static getPersonThumbnailPath(person: PersonThumbnailPathEntity) { + return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.personGroupId}.jpeg`); } static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) { @@ -177,12 +180,13 @@ export class StorageCore { }); } - async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) { - const { id: entityId, thumbnailPath } = person; + async movePersonFile(person: PersonThumbnailPathEntity & { thumbnailPath: string }, pathType: PersonPathType) { + const { ownerId, personGroupId, thumbnailPath } = person; switch (pathType) { case PersonPathType.Face: { await this.moveFile({ - entityId, + entityId: personGroupId, + ownerId, pathType, oldPath: thumbnailPath, newPath: StorageCore.getPersonThumbnailPath(person), @@ -192,7 +196,7 @@ export class StorageCore { } async moveFile(request: MoveRequest) { - const { entityId, pathType, oldPath, newPath, assetInfo } = request; + const { entityId, ownerId, pathType, oldPath, newPath, assetInfo } = request; if (!oldPath || oldPath === newPath) { return; } @@ -265,7 +269,7 @@ export class StorageCore { } } - await this.savePath(pathType, entityId, newPath); + await this.savePath(pathType, entityId, newPath, ownerId); await this.moveRepository.delete(move.id); } @@ -318,7 +322,7 @@ export class StorageCore { return { dri, mali }; } - private savePath(pathType: PathType, id: string, newPath: string) { + private savePath(pathType: PathType, id: string, newPath: string, ownerId?: string) { switch (pathType) { case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); @@ -334,7 +338,12 @@ export class StorageCore { } case PersonPathType.Face: { - return this.personRepository.update({ id, thumbnailPath: newPath }); + if (!ownerId) { + this.logger.warn('Unable to save person path without an owner'); + return; + } + + return this.personRepository.update({ ownerId, personGroupId: id, thumbnailPath: newPath }); } case UserPathType.Profile: { @@ -352,10 +361,6 @@ export class StorageCore { return join(StorageCore.getNestedFolder(folder, ownerId, filename), filename); } - static getTempPathInDir(dir: string): string { - return join(dir, `${randomUUID()}.tmp`); - } - private async getDevices() { try { return await this.storageRepository.readdir('/dev/dri'); diff --git a/server/src/database.ts b/server/src/database.ts index 100ba451e7..2d04b13825 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -133,6 +133,7 @@ export type User = { }; export type UserAdmin = User & { + clusterGroupId: string; storageLabel: string | null; shouldChangePassword: boolean; isAdmin: boolean; @@ -241,7 +242,7 @@ export type Exif = Omit, 'updatedAt' | 'updateId' | ' export type Person = { createdAt: Date; - id: string; + personGroupId: string; ownerId: string; updatedAt: Date; updateId: string; @@ -264,7 +265,7 @@ export type AssetFace = { boundingBoxY2: number; imageHeight: number; imageWidth: number; - personId: string | null; + personGroupId: string | null; sourceType: SourceType; person?: ShallowDehydrateObject | null; updatedAt: Date; @@ -376,6 +377,7 @@ export const columns = { userWithPrefix: userWithPrefixColumns, userAdmin: [ ...userColumns, + 'clusterGroupId', 'createdAt', 'updatedAt', 'deletedAt', diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 07398b0058..b8556be41c 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -141,7 +141,7 @@ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GEN export type EventConfig = { name: EmitEvent; - /** handle socket.io server events as well */ + /** handle socket.io server events as well */ server?: boolean; /** lower value has higher priority, defaults to 0 */ priority?: number; diff --git a/server/src/dtos/api-key.dto.ts b/server/src/dtos/api-key.dto.ts index 470808e430..8c933e06bb 100644 --- a/server/src/dtos/api-key.dto.ts +++ b/server/src/dtos/api-key.dto.ts @@ -1,4 +1,5 @@ import { createZodDto } from 'nestjs-zod'; +import { HistoryBuilder } from 'src/decorators'; import { Permission } from 'src/enum'; import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; @@ -31,8 +32,9 @@ const ApiKeyResponseSchema = z const ApiKeyCreateResponseSchema = z .object({ + ...ApiKeyResponseSchema.shape, secret: z.string().describe('API key secret (only shown once)'), - apiKey: ApiKeyResponseSchema, + apiKey: ApiKeyResponseSchema.meta({ ...new HistoryBuilder().added('v1').deprecated('v3.2.0').getExtensions() }), }) .meta({ id: 'ApiKeyCreateResponseDto' }); diff --git a/server/src/dtos/asset-file.dto.ts b/server/src/dtos/asset-file.dto.ts new file mode 100644 index 0000000000..54a772cd88 --- /dev/null +++ b/server/src/dtos/asset-file.dto.ts @@ -0,0 +1,45 @@ +import { Selectable } from 'kysely'; +import { createZodDto } from 'nestjs-zod'; +import { AssetFileTypeSchema } from 'src/enum'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { isoDatetimeToDate, stringToBool } from 'src/validation'; +import z from 'zod'; + +const AssetFileSearchSchema = z + .object({ + assetId: z.uuidv4().describe('Asset ID to filter files by'), + type: AssetFileTypeSchema.optional().describe('Filter by type of file'), + isEdited: stringToBool.optional().describe('The file was generated from an edit'), + isProgressive: stringToBool.optional().describe('The file is a progressively encoded JPEG'), + isTransparent: stringToBool.optional().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileSearchDto' }); + +const AssetFileResponseSchema = z + .object({ + id: z.uuidv4().describe('Asset file ID'), + createdAt: isoDatetimeToDate.describe('Creation date'), + updatedAt: isoDatetimeToDate.describe('Update date'), + type: AssetFileTypeSchema.describe('Type of file'), + path: z.string().describe('File path'), + isEdited: z.boolean().describe('The file was generated from an edit'), + isProgressive: z.boolean().describe('The file is a progressively encoded JPEG'), + isTransparent: z.boolean().describe('The file is transparent'), + }) + .meta({ id: 'AssetFileResponseDto' }); + +export class AssetFileSearchDto extends createZodDto(AssetFileSearchSchema) {} +export class AssetFileResponseDto extends createZodDto(AssetFileResponseSchema) {} + +export const mapAssetFile = (file: Selectable): AssetFileResponseDto => { + return { + id: file.id, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + type: file.type, + path: file.path, + isEdited: file.isEdited, + isProgressive: file.isProgressive, + isTransparent: file.isTransparent, + }; +}; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index c54ce1cbab..eaa05b40dc 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -170,8 +170,8 @@ const peopleFromFaces = (faces?: MaybeDehydrated[]): PersonResponseDt const peopleMap: Map = new Map(); for (const face of faces) { - if (face.person && !peopleMap.has(face.person.id)) { - peopleMap.set(face.person.id, mapPerson(face.person)); + if (face.person && !peopleMap.has(face.person.personGroupId)) { + peopleMap.set(face.person.personGroupId, mapPerson(face.person)); } } diff --git a/server/src/dtos/cluster-group.dto.ts b/server/src/dtos/cluster-group.dto.ts new file mode 100644 index 0000000000..d7de9d08ee --- /dev/null +++ b/server/src/dtos/cluster-group.dto.ts @@ -0,0 +1,32 @@ +import { Selectable } from 'kysely'; +import { createZodDto } from 'nestjs-zod'; +import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table'; +import { isoDatetimeToDate } from 'src/validation'; +import z from 'zod'; + +const ClusterGroupRequestCreateSchema = z + .object({ + userId: z.uuidv4().describe('User to invite into the cluster group'), + }) + .meta({ id: 'ClusterGroupRequestCreateDto' }); + +const ClusterGroupRequestResponseSchema = z + .object({ + id: z.uuidv4().describe('Request ID'), + clusterGroupId: z.uuidv4().describe('Cluster group the user is invited to join'), + userId: z.uuidv4().describe('User the request was created for'), + createdAt: isoDatetimeToDate.describe('Creation date'), + }) + .meta({ id: 'ClusterGroupRequestResponseDto' }); + +export class ClusterGroupRequestCreateDto extends createZodDto(ClusterGroupRequestCreateSchema) {} +export class ClusterGroupRequestResponseDto extends createZodDto(ClusterGroupRequestResponseSchema) {} + +export function mapClusterGroupRequest(request: Selectable): ClusterGroupRequestResponseDto { + return { + id: request.id, + clusterGroupId: request.clusterGroupId, + userId: request.userId, + createdAt: request.createdAt, + }; +} diff --git a/server/src/dtos/config.dto.spec.ts b/server/src/dtos/config.dto.spec.ts new file mode 100644 index 0000000000..ce4f5a5d06 --- /dev/null +++ b/server/src/dtos/config.dto.spec.ts @@ -0,0 +1,68 @@ +import { + AdminConfigDto, + defaults, + mapPublicConfig, + mapUserConfig, + PublicConfigDto, + UserConfigDto, +} from 'src/dtos/config.dto'; +import { getKeysDeep } from 'src/utils/misc'; +import z from 'zod'; + +const PUBLIC_PROPERTIES = [ + 'oauth.autoLaunch', + 'oauth.buttonText', + 'oauth.enabled', + 'passwordLogin.enabled', + 'server.loginPageMessage', + 'theme.customCss', +]; + +describe('config visibility', () => { + it('should expose every property to admins', () => { + const paths = getKeysDeep(defaults); + + expect(paths).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES)); + expect(paths).toContain('oauth.clientSecret'); + expect(paths.length).toBeGreaterThan(100); + }); + + it('should expose the public properties to everyone', () => { + expect(getKeysDeep(mapPublicConfig(defaults)).sort()).toEqual(PUBLIC_PROPERTIES); + }); + + it('should expose everything public to logged in users as well', () => { + expect(getKeysDeep(mapUserConfig(defaults))).toEqual(expect.arrayContaining(PUBLIC_PROPERTIES)); + }); + + it('should accept the defaults with the admin schema', () => { + expect(AdminConfigDto.schema.safeParse(defaults)).toEqual(expect.objectContaining({ success: true })); + }); + + it('should map the defaults onto the user and public schemas', () => { + expect(UserConfigDto.schema.safeParse(mapUserConfig(defaults))).toEqual(expect.objectContaining({ success: true })); + expect(PublicConfigDto.schema.safeParse(mapPublicConfig(defaults))).toEqual( + expect.objectContaining({ success: true }), + ); + }); + + it('should not leak admin properties into the public config', () => { + const config = mapPublicConfig(defaults) as Record; + + expect(config.oauth).toEqual({ + autoLaunch: defaults.oauth.autoLaunch, + buttonText: defaults.oauth.buttonText, + enabled: defaults.oauth.enabled, + }); + expect(config.job).toBeUndefined(); + expect(config.image).toBeUndefined(); + expect(config.notifications).toBeUndefined(); + }); + + it('should keep the visibility metadata out of the schemas', () => { + for (const schema of [AdminConfigDto.schema, UserConfigDto.schema, PublicConfigDto.schema]) { + const json = JSON.stringify(z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' })); + expect(json).not.toContain('visibility'); + } + }); +}); diff --git a/server/src/dtos/config.dto.ts b/server/src/dtos/config.dto.ts new file mode 100644 index 0000000000..b0f99996eb --- /dev/null +++ b/server/src/dtos/config.dto.ts @@ -0,0 +1,778 @@ +import { CronExpression } from '@nestjs/schedule'; +import { validateCronExpression } from 'cron'; +import { createZodDto } from 'nestjs-zod'; +import { + AudioCodec, + AudioCodecSchema, + Colorspace, + ColorspaceSchema, + ConfigVisibility, + CQMode, + CQModeSchema, + HlsVideoResolution, + HlsVideoResolutionSchema, + ImageFormat, + ImageFormatSchema, + LogLevel, + LogLevelSchema, + OAuthTokenEndpointAuthMethod, + OAuthTokenEndpointAuthMethodSchema, + ReleaseChannel, + ReleaseChannelSchema, + ToneMapping, + ToneMappingSchema, + TranscodeHardwareAcceleration, + TranscodeHardwareAccelerationSchema, + TranscodePolicy, + TranscodePolicySchema, + VideoCodec, + VideoCodecSchema, + VideoContainer, + VideoContainerSchema, +} from 'src/enum'; +import { DeepPartial } from 'src/types'; +import z from 'zod'; + +const { Admin, User, Public } = ConfigVisibility; + +const configBool = z + .preprocess((val) => { + if (val === 'true') { + return true; + } + if (val === 'false') { + return false; + } + return val; + }, z.boolean()) + .meta({ type: 'boolean' }); + +const cronExpressionSchema = z + .string() + .superRefine((value, ctx) => { + const validated = validateCronExpression(value); + if (!validated.valid) { + ctx.addIssue({ + code: 'custom', + message: `Invalid cron expression. ${validated.error?.message ?? ''}`, + input: value, + }); + } + }) + .describe('Cron expression'); + +const emptyOrUrl = (error: string) => + z.string().refine((url) => url.length === 0 || z.url().safeParse(url).success, { error }); + +const AdminConfigIntegrityJobSchema = z + .object({ + enabled: z.boolean().describe('Enabled'), + cronExpression: cronExpressionSchema.describe('Cron expression for when the integrity check should run'), + }) + .describe('Integrity job config') + .meta({ id: 'AdminConfigIntegrityJobDto' }); + +const AdminConfigJobSettingsSchema = z + .object({ concurrency: z.int().min(1).describe('Concurrency') }) + .meta({ id: 'AdminConfigJobSettingsDto' }); + +const AdminConfigMachineLearningTaskSchema = z.object({ + enabled: z.boolean().describe('Whether the task is enabled').meta({ visibility: User }), +}); + +const AdminConfigMachineLearningModelSchema = AdminConfigMachineLearningTaskSchema.extend({ + modelName: z.string().describe('Name of the model to use'), +}); + +const AdminConfigGeneratedImageSchema = z + .object({ + format: ImageFormatSchema, + quality: z.int().min(1).max(100).describe('Quality'), + size: z.int().min(1).describe('Size').meta({ visibility: User }), + progressive: configBool.default(false).optional().describe('Progressive'), + }) + .meta({ id: 'AdminConfigGeneratedImageDto' }); + +const AdminConfigFFmpegSchema = z + .object({ + crf: z.coerce.number().int().min(0).max(51).describe('CRF'), + threads: z.coerce.number().int().min(0).describe('Threads'), + preset: z.string().describe('Preset'), + targetVideoCodec: VideoCodecSchema, + acceptedVideoCodecs: z.array(VideoCodecSchema).describe('Accepted video codecs'), + targetAudioCodec: AudioCodecSchema, + acceptedAudioCodecs: z.array(AudioCodecSchema).describe('Accepted audio codecs'), + acceptedContainers: z.array(VideoContainerSchema).describe('Accepted containers'), + targetResolution: z.string().describe('Target resolution'), + maxBitrate: z.string().describe('Max bitrate'), + bframes: z.coerce.number().int().min(-1).max(16).describe('B-frames'), + refs: z.coerce.number().int().min(0).max(6).describe('References'), + gopSize: z.coerce.number().int().min(0).describe('GOP size'), + temporalAQ: configBool.describe('Temporal AQ'), + cqMode: CQModeSchema, + twoPass: configBool.describe('Two pass'), + preferredHwDevice: z.string().describe('Preferred hardware device'), + transcode: TranscodePolicySchema, + accel: TranscodeHardwareAccelerationSchema, + accelDecode: configBool.describe('Accelerated decode'), + tonemap: ToneMappingSchema, + realtime: z + .object({ + enabled: configBool.describe('Enable real-time HLS transcoding (alpha)').meta({ visibility: User }), + videoCodecs: z + .array(VideoCodecSchema) + .describe('Video codecs to use for real-time HLS transcoding') + .meta({ visibility: User }), + resolutions: z + .array(HlsVideoResolutionSchema) + .describe('Resolutions to use for real-time HLS transcoding') + .meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigFFmpegRealtimeDto' }), + }) + .meta({ id: 'AdminConfigFFmpegDto' }); + +const AdminConfigSmtpSchema = z + .object({ + enabled: configBool.describe('Whether SMTP email notifications are enabled'), + from: z.string().describe('Email address to send from'), + replyTo: z.string().describe('Email address for replies'), + transport: z + .object({ + ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'), + host: z.string().describe('SMTP server hostname'), + port: z.int().min(0).max(65_535).describe('SMTP server port'), + secure: configBool.describe('Whether to use secure connection (TLS/SSL)'), + username: z.string().describe('SMTP username'), + password: z.string().describe('SMTP password'), + }) + .meta({ id: 'AdminConfigSmtpTransportDto' }), + }) + .meta({ id: 'AdminConfigSmtpDto' }); + +const AdminConfigSchemaWithVisibility = z + .object({ + backup: z + .object({ + database: z + .object({ + enabled: configBool.describe('Enabled'), + cronExpression: cronExpressionSchema, + keepLastAmount: z.int().min(1).describe('Keep last amount'), + }) + .meta({ id: 'AdminConfigDatabaseBackupDto' }), + }) + .meta({ id: 'AdminConfigBackupsDto' }), + ffmpeg: AdminConfigFFmpegSchema, + integrityChecks: z + .object({ + missingFiles: AdminConfigIntegrityJobSchema, + untrackedFiles: AdminConfigIntegrityJobSchema, + checksumFiles: AdminConfigIntegrityJobSchema.extend({ + timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'), + percentageLimit: z + .float32() + .nonnegative() + .max(1) + .describe('Percentage limit of the integrity checksum job') + .meta({ format: 'double' }), + }) + .describe('Integrity checksum job config') + .meta({ id: 'AdminConfigIntegrityChecksumJobDto' }), + }) + .describe('Integrity checks config') + .meta({ id: 'AdminConfigIntegrityChecksDto' }), + job: z + .object({ + thumbnailGeneration: AdminConfigJobSettingsSchema, + metadataExtraction: AdminConfigJobSettingsSchema, + videoConversion: AdminConfigJobSettingsSchema, + faceDetection: AdminConfigJobSettingsSchema, + smartSearch: AdminConfigJobSettingsSchema, + backgroundTask: AdminConfigJobSettingsSchema, + migration: AdminConfigJobSettingsSchema, + search: AdminConfigJobSettingsSchema, + sidecar: AdminConfigJobSettingsSchema, + library: AdminConfigJobSettingsSchema, + notifications: AdminConfigJobSettingsSchema, + ocr: AdminConfigJobSettingsSchema, + workflow: AdminConfigJobSettingsSchema, + editor: AdminConfigJobSettingsSchema, + integrityCheck: AdminConfigJobSettingsSchema, + }) + .meta({ id: 'AdminConfigJobDto' }), + logging: z + .object({ + enabled: configBool.describe('Enabled'), + level: LogLevelSchema, + }) + .meta({ id: 'AdminConfigLoggingDto' }), + machineLearning: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + urls: z.array(z.string()).min(1).describe('ML service URLs'), + availabilityChecks: z + .object({ + enabled: configBool.describe('Enabled'), + timeout: z.int(), + interval: z.int(), + }) + .meta({ id: 'AdminConfigMachineLearningAvailabilityChecksDto' }), + clip: AdminConfigMachineLearningModelSchema.meta({ id: 'AdminConfigClipDto' }), + duplicateDetection: AdminConfigMachineLearningTaskSchema.extend({ + maxDistance: z + .number() + .min(0.001) + .max(0.1) + .describe('Maximum distance threshold for duplicate detection') + .meta({ format: 'double' }), + }).meta({ id: 'AdminConfigDuplicateDetectionDto' }), + facialRecognition: AdminConfigMachineLearningModelSchema.extend({ + minScore: z + .number() + .min(0.1) + .max(1) + .describe('Minimum confidence score for face detection') + .meta({ format: 'double' }), + maxDistance: z + .number() + .min(0.1) + .max(2) + .describe('Maximum distance threshold for face recognition') + .meta({ format: 'double' }), + minFaces: z + .int() + .min(1) + .describe('Minimum number of faces required for recognition') + .meta({ visibility: User }), + }).meta({ id: 'AdminConfigFacialRecognitionDto' }), + ocr: AdminConfigMachineLearningModelSchema.extend({ + maxResolution: z.int().min(1).describe('Maximum resolution for OCR processing'), + minDetectionScore: z + .number() + .min(0.1) + .max(1) + .describe('Minimum confidence score for text detection') + .meta({ format: 'double' }), + minRecognitionScore: z + .number() + .min(0.1) + .max(1) + .describe('Minimum confidence score for text recognition') + .meta({ format: 'double' }), + }).meta({ id: 'AdminConfigOcrDto' }), + }) + .meta({ id: 'AdminConfigMachineLearningDto' }), + map: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + lightStyle: z.url().describe('Light map style URL').meta({ visibility: User }), + darkStyle: z.url().describe('Dark map style URL').meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigMapDto' }), + reverseGeocoding: z + .object({ enabled: configBool.describe('Enabled').meta({ visibility: User }) }) + .meta({ id: 'AdminConfigReverseGeocodingDto' }), + metadata: z + .object({ + faces: z.object({ import: configBool.describe('Import') }).meta({ id: 'AdminConfigFacesDto' }), + }) + .meta({ id: 'AdminConfigMetadataDto' }), + oauth: z + .object({ + autoLaunch: configBool.describe('Auto launch').meta({ visibility: Public }), + autoRegister: configBool.describe('Auto register'), + buttonText: z.string().describe('Button text').meta({ visibility: Public }), + clientId: z.string().describe('Client ID'), + clientSecret: z.string().describe('Client secret'), + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema, + timeout: z.int().min(1).describe('Timeout'), + allowInsecureRequests: configBool.describe('Allow insecure requests'), + defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'), + enabled: configBool.describe('Enabled').meta({ visibility: Public }), + issuerUrl: emptyOrUrl('Issuer URL must be an empty string or a valid URL').describe('Issuer URL'), + accountManagementUrl: emptyOrUrl('Account management URL must be an empty string or a valid URL') + .describe('Account management URL') + .optional() + .default(''), + scope: z.string().describe('Scope'), + prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'), + endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe( + 'End session endpoint', + ), + signingAlgorithm: z.string().describe('Signing algorithm'), + profileSigningAlgorithm: z.string().describe('Profile signing algorithm'), + storageLabelClaim: z.string().describe('Storage label claim'), + storageQuotaClaim: z.string().describe('Storage quota claim'), + roleClaim: z.string().describe('Role claim'), + mobileOverrideEnabled: configBool.describe('Mobile override enabled'), + mobileRedirectUri: z.string().describe('Mobile redirect URI (set to empty string to disable)'), + }) + .transform((value, ctx) => { + if (!value.mobileOverrideEnabled || value.mobileRedirectUri === '') { + return value; + } + + if (!z.url().safeParse(value.mobileRedirectUri).success) { + ctx.issues.push({ + code: 'custom', + message: 'Mobile redirect URI must be an empty string or a valid URL', + input: value.mobileRedirectUri, + }); + return z.NEVER; + } + + return value; + }) + .meta({ id: 'AdminConfigOAuthDto' }), + passwordLogin: z + .object({ enabled: configBool.describe('Enabled').meta({ visibility: Public }) }) + .meta({ id: 'AdminConfigPasswordLoginDto' }), + storageTemplate: z + .object({ + enabled: configBool.describe('Enabled'), + hashVerificationEnabled: configBool.describe('Hash verification enabled'), + template: z.string().describe('Template'), + }) + .meta({ id: 'AdminConfigStorageTemplateDto' }), + image: z + .object({ + thumbnail: AdminConfigGeneratedImageSchema, + preview: AdminConfigGeneratedImageSchema, + fullsize: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + format: ImageFormatSchema, + quality: z.int().min(1).max(100).describe('Quality'), + progressive: configBool.default(false).optional().describe('Progressive'), + }) + .meta({ id: 'AdminConfigGeneratedFullsizeImageDto' }), + colorspace: ColorspaceSchema, + extractEmbedded: configBool.describe('Extract embedded'), + }) + .meta({ id: 'AdminConfigImageDto' }), + newVersionCheck: z + .object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema }) + .meta({ id: 'AdminConfigNewVersionCheckDto' }), + nightlyTasks: z + .object({ + startTime: z.iso + .time({ + precision: -1, + error: (iss) => `Invalid input: expected string in HH:MM format, received ${typeof iss.input}`, + }) + .describe('Start time (HH:MM)'), + databaseCleanup: configBool.describe('Database cleanup'), + missingThumbnails: configBool.describe('Missing thumbnails'), + clusterNewFaces: configBool.describe('Cluster new faces'), + generateMemories: configBool.describe('Generate memories'), + syncQuotaUsage: configBool.describe('Sync quota usage'), + }) + .meta({ id: 'AdminConfigNightlyTasksDto' }), + trash: z + .object({ + enabled: configBool.describe('Enabled').meta({ visibility: User }), + days: z.int().min(0).describe('Days').meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigTrashDto' }), + theme: z + .object({ customCss: z.string().describe('Custom CSS for theming').meta({ visibility: Public }) }) + .meta({ id: 'AdminConfigThemeDto' }), + library: z + .object({ + scan: z + .object({ + enabled: configBool.describe('Enabled'), + cronExpression: cronExpressionSchema, + }) + .meta({ id: 'AdminConfigLibraryScanDto' }), + watch: z.object({ enabled: configBool.describe('Enabled') }).meta({ id: 'AdminConfigLibraryWatchDto' }), + }) + .meta({ id: 'AdminConfigLibraryDto' }), + notifications: z.object({ smtp: AdminConfigSmtpSchema }).meta({ id: 'AdminConfigNotificationsDto' }), + templates: z + .object({ + email: z + .object({ + welcomeTemplate: z.string().describe('Welcome template'), + albumInviteTemplate: z.string().describe('Album invite template'), + albumUpdateTemplate: z.string().describe('Album update template'), + }) + .meta({ id: 'AdminConfigTemplateEmailsDto' }), + }) + .meta({ id: 'AdminConfigTemplatesDto' }), + server: z + .object({ + externalDomain: emptyOrUrl('External domain must be an empty string or a valid URL') + .describe('External domain') + .meta({ visibility: User }), + loginPageMessage: z.string().describe('Login page message').meta({ visibility: Public }), + publicUsers: configBool.describe('Public users').meta({ visibility: User }), + }) + .meta({ id: 'AdminConfigServerDto' }), + user: z + .object({ deleteDelay: z.int().min(1).describe('Delete delay').meta({ visibility: User }) }) + .meta({ id: 'AdminConfigUserDto' }), + }) + .describe('Configuration properties that are visible to the admin') + .meta({ id: 'AdminConfigDto' }); + +export type SystemConfig = z.infer; +export type MachineLearningConfig = SystemConfig['machineLearning']; + +const visibilities = [Public, User, Admin]; + +const isVisible = (property: ConfigVisibility, visibility: ConfigVisibility) => + visibilities.indexOf(property) <= visibilities.indexOf(visibility); + +const getMeta = (schema: z.ZodType) => + (z.globalRegistry.get(schema) ?? {}) as { id?: string; description?: string; visibility?: ConfigVisibility }; + +const unwrap = (schema: z.ZodType) => (schema instanceof z.ZodPipe ? (schema.def.in as z.ZodType) : schema); + +const visibleSchemas = new Map>(); + +const applyVisibility = (visibility: ConfigVisibility): z.ZodType | undefined => { + const map: Record = { + [Admin]: 'Configuration properties that are visible to the admin', + [User]: 'Configuration properties that are visible to a logged user', + [Public]: 'Configuration properties that are visible to everyone', + }; + + return applyVisibilityRecursive(AdminConfigSchemaWithVisibility, visibility, map[visibility]); +}; + +const applyVisibilityRecursive = ( + schema: z.ZodType, + visibility: ConfigVisibility, + override?: string, +): z.ZodType | undefined => { + const object = unwrap(schema); + const { id, description, visibility: property } = getMeta(schema); + + if (!(object instanceof z.ZodObject)) { + return isVisible(property ?? Admin, visibility) ? schema : undefined; + } + + let cache = visibleSchemas.get(schema); + if (!cache) { + cache = new Map(); + visibleSchemas.set(schema, cache); + } + + if (cache.has(visibility)) { + return cache.get(visibility); + } + + const shape: Record = {}; + for (const [key, value] of Object.entries(object.shape as Record)) { + const visible = applyVisibilityRecursive(value, visibility); + if (visible) { + shape[key] = visible; + } + } + + let visible: z.ZodType | undefined; + if (Object.keys(shape).length > 0) { + visible = z.object(shape).meta({ + ...(id && { id: `${visibility}${id.slice(Admin.length)}` }), + ...((override ?? description) && { description: override ?? description }), + }); + } + + cache.set(visibility, visible); + + return visible; +}; + +const stripVisibilityMetadata = (schema: T): T => { + const object = unwrap(schema); + if (object instanceof z.ZodObject) { + for (const value of Object.values(object.shape as Record)) { + stripVisibilityMetadata(value); + } + + return schema; + } + + const { visibility, ...meta } = getMeta(schema); + if (visibility) { + z.globalRegistry.add(schema, meta); + } + + return schema; +}; + +const AdminConfigSchema = applyVisibility(Admin)! as z.ZodType; +const UserConfigSchema = applyVisibility(User)! as z.ZodType>; +const PublicConfigSchema = applyVisibility(Public)! as z.ZodType>; + +// prevent visibility metadata from leaking to openapi spec +// eslint-disable-next-line unicorn/no-top-level-side-effects +stripVisibilityMetadata(AdminConfigSchemaWithVisibility); + +const ConfigTemplateStorageOptionSchema = z + .object({ + yearOptions: z.array(z.string()).describe('Available year format options for storage template'), + monthOptions: z.array(z.string()).describe('Available month format options for storage template'), + weekOptions: z.array(z.string()).describe('Available week format options for storage template'), + dayOptions: z.array(z.string()).describe('Available day format options for storage template'), + hourOptions: z.array(z.string()).describe('Available hour format options for storage template'), + minuteOptions: z.array(z.string()).describe('Available minute format options for storage template'), + secondOptions: z.array(z.string()).describe('Available second format options for storage template'), + presetOptions: z.array(z.string()).describe('Available preset template options'), + }) + .meta({ id: 'SystemConfigTemplateStorageOptionDto' }); + +export class AdminConfigDto extends createZodDto(AdminConfigSchema) {} +export class UserConfigDto extends createZodDto(UserConfigSchema) {} +export class PublicConfigDto extends createZodDto(PublicConfigSchema) {} +export class ConfigFFmpegDto extends createZodDto(AdminConfigFFmpegSchema) {} +export class ConfigSmtpDto extends createZodDto(AdminConfigSmtpSchema) {} +export class ConfigTemplateStorageOptionDto extends createZodDto(ConfigTemplateStorageOptionSchema) {} + +/** @deprecated the `/system-config` endpoints these are named after are on their way out */ +export { AdminConfigDto as SystemConfigDto, ConfigSmtpDto as SystemConfigSmtpDto }; + +export function mapAdminConfig(config: SystemConfig): AdminConfigDto { + return config; +} + +export function mapUserConfig(config: SystemConfig): UserConfigDto { + return UserConfigSchema.parse(config); +} + +export function mapPublicConfig(config: SystemConfig): PublicConfigDto { + return PublicConfigSchema.parse(config); +} + +export const defaults = Object.freeze({ + backup: { + database: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_2AM, + keepLastAmount: 14, + }, + }, + ffmpeg: { + crf: 23, + threads: 0, + preset: 'ultrafast', + targetVideoCodec: VideoCodec.H264, + acceptedVideoCodecs: [VideoCodec.H264], + targetAudioCodec: AudioCodec.Aac, + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus], + acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], + targetResolution: '720', + maxBitrate: '0', + bframes: -1, + refs: 0, + gopSize: 0, + temporalAQ: false, + cqMode: CQMode.Auto, + twoPass: false, + preferredHwDevice: 'auto', + transcode: TranscodePolicy.Required, + tonemap: ToneMapping.Hable, + accel: TranscodeHardwareAcceleration.Disabled, + accelDecode: true, + realtime: { + enabled: false, + videoCodecs: [VideoCodec.H264, VideoCodec.Hevc], + resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080], + }, + }, + integrityChecks: { + missingFiles: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_3AM, + }, + untrackedFiles: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_3AM, + }, + checksumFiles: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_3AM, + timeLimit: 60 * 60 * 1000, // 1 hour + percentageLimit: 1, // 100% of assets + }, + }, + job: { + thumbnailGeneration: { concurrency: 3 }, + metadataExtraction: { concurrency: 5 }, + videoConversion: { concurrency: 1 }, + faceDetection: { concurrency: 2 }, + smartSearch: { concurrency: 2 }, + backgroundTask: { concurrency: 5 }, + migration: { concurrency: 5 }, + search: { concurrency: 5 }, + sidecar: { concurrency: 5 }, + library: { concurrency: 5 }, + notifications: { concurrency: 5 }, + ocr: { concurrency: 1 }, + workflow: { concurrency: 5 }, + editor: { concurrency: 2 }, + integrityCheck: { concurrency: 1 }, + }, + logging: { + enabled: true, + level: LogLevel.Log, + }, + machineLearning: { + enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', + urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], + availabilityChecks: { + enabled: true, + timeout: 2000, + interval: 30_000, + }, + clip: { + enabled: true, + modelName: 'ViT-B-32__openai', + }, + duplicateDetection: { + enabled: true, + maxDistance: 0.01, + }, + facialRecognition: { + enabled: true, + modelName: 'buffalo_l', + minScore: 0.7, + maxDistance: 0.5, + minFaces: 3, + }, + ocr: { + enabled: true, + modelName: 'PP-OCRv5_mobile', + minDetectionScore: 0.5, + minRecognitionScore: 0.8, + maxResolution: 736, + }, + }, + map: { + enabled: true, + lightStyle: 'https://tiles.immich.cloud/v1/style/light.json', + darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json', + }, + reverseGeocoding: { + enabled: true, + }, + metadata: { + faces: { + import: false, + }, + }, + oauth: { + autoLaunch: false, + autoRegister: true, + buttonText: 'Login with OAuth', + clientId: '', + clientSecret: '', + defaultStorageQuota: null, + enabled: false, + issuerUrl: '', + accountManagementUrl: '', + endSessionEndpoint: '', + mobileOverrideEnabled: false, + mobileRedirectUri: '', + prompt: '', + scope: 'openid email profile', + signingAlgorithm: 'RS256', + profileSigningAlgorithm: 'none', + storageLabelClaim: 'preferred_username', + storageQuotaClaim: 'immich_quota', + roleClaim: 'immich_role', + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost, + timeout: 30_000, + allowInsecureRequests: false, + }, + passwordLogin: { + enabled: true, + }, + storageTemplate: { + enabled: false, + hashVerificationEnabled: true, + template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + }, + image: { + thumbnail: { + format: ImageFormat.Webp, + size: 250, + quality: 80, + progressive: false, + }, + preview: { + format: ImageFormat.Jpeg, + size: 1440, + quality: 80, + progressive: false, + }, + colorspace: Colorspace.P3, + extractEmbedded: false, + fullsize: { + enabled: false, + format: ImageFormat.Jpeg, + quality: 80, + progressive: false, + }, + }, + newVersionCheck: { + enabled: true, + channel: ReleaseChannel.Stable, + }, + nightlyTasks: { + startTime: '00:00', + databaseCleanup: true, + generateMemories: true, + syncQuotaUsage: true, + missingThumbnails: true, + clusterNewFaces: true, + }, + trash: { + enabled: true, + days: 30, + }, + theme: { + customCss: '', + }, + library: { + scan: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_MIDNIGHT, + }, + watch: { + enabled: false, + }, + }, + server: { + externalDomain: '', + loginPageMessage: '', + publicUsers: true, + }, + notifications: { + smtp: { + enabled: false, + from: '', + replyTo: '', + transport: { + ignoreCert: false, + host: '', + port: 587, + secure: false, + username: '', + password: '', + }, + }, + }, + templates: { + email: { + welcomeTemplate: '', + albumInviteTemplate: '', + albumUpdateTemplate: '', + }, + }, + user: { + deleteDelay: 7, + }, +}); diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index c37fa5c113..fd61557d0c 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -1,5 +1,4 @@ import { createZodDto } from 'nestjs-zod'; -import { AssetIdsSchema } from 'src/dtos/asset.dto'; import z from 'zod'; const DownloadInfoSchema = z @@ -25,9 +24,29 @@ const DownloadResponseSchema = z }) .meta({ id: 'DownloadResponseDto' }); -const DownloadArchiveSchema = AssetIdsSchema.extend({ - edited: z.boolean().optional().describe('Download edited asset if available'), -}).meta({ id: 'DownloadArchiveDto' }); +const DownloadArchiveSchema = z + .object({ + // Support receiving assetIds as a comma-separated string due to POST form submission. + // While we can send arrays, the total request parameter count is limited to 1000 fields, + // which would limit this DTO to a maximum of 1000 assets. + assetIds: z + .preprocess((val) => (typeof val === 'string' ? val.split(',') : val), z.array(z.uuidv4())) + .describe('Asset IDs'), + edited: z + .preprocess((val) => { + if (val === 'true') { + return true; + } + if (val === 'false') { + return false; + } + return val; + }, z.boolean()) + .optional() + .describe('Download edited asset if available'), + archiveName: z.string().optional().describe('The name of the archive to download, without extension'), + }) + .meta({ id: 'DownloadArchiveDto' }); export class DownloadInfoDto extends createZodDto(DownloadInfoSchema) {} export class DownloadResponseDto extends createZodDto(DownloadResponseSchema) {} diff --git a/server/src/dtos/model-config.dto.ts b/server/src/dtos/model-config.dto.ts deleted file mode 100644 index 2ba6f0c365..0000000000 --- a/server/src/dtos/model-config.dto.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createZodDto } from 'nestjs-zod'; -import z from 'zod'; - -const TaskConfigSchema = z - .object({ - enabled: z.boolean().describe('Whether the task is enabled'), - }) - .meta({ id: 'TaskConfig' }); - -const ModelConfigSchema = TaskConfigSchema.extend({ - modelName: z.string().describe('Name of the model to use'), -}); - -export const CLIPConfigSchema = ModelConfigSchema.meta({ id: 'CLIPConfig' }); - -export const DuplicateDetectionConfigSchema = TaskConfigSchema.extend({ - maxDistance: z - .number() - .meta({ format: 'double' }) - .min(0.001) - .max(0.1) - .describe('Maximum distance threshold for duplicate detection'), -}).meta({ id: 'DuplicateDetectionConfig' }); - -export const FacialRecognitionConfigSchema = ModelConfigSchema.extend({ - minScore: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(1) - .describe('Minimum confidence score for face detection'), - maxDistance: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(2) - .describe('Maximum distance threshold for face recognition'), - minFaces: z.int().min(1).describe('Minimum number of faces required for recognition'), -}).meta({ id: 'FacialRecognitionConfig' }); - -export const OcrConfigSchema = ModelConfigSchema.extend({ - maxResolution: z.int().min(1).describe('Maximum resolution for OCR processing'), - minDetectionScore: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(1) - .describe('Minimum confidence score for text detection'), - minRecognitionScore: z - .number() - .meta({ format: 'double' }) - .min(0.1) - .max(1) - .describe('Minimum confidence score for text recognition'), -}).meta({ id: 'OcrConfig' }); - -export class CLIPConfig extends createZodDto(CLIPConfigSchema) {} diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index fa56ded176..0379b6f3b0 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -173,7 +173,7 @@ export class PeopleResponseDto extends createZodDto(PeopleResponseSchema) {} export function mapPerson(person: MaybeDehydrated): PersonResponseDto { return { - id: person.id, + id: person.personGroupId, name: person.name, birthDate: asDateString(person.birthDate), thumbnailPath: person.thumbnailPath, @@ -215,6 +215,6 @@ export function mapFaces( ): AssetFaceResponseDto { return { ...mapFacesWithoutPerson(face, edits, assetDimensions), - person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null, + person: face.person ? mapPerson(face.person) : null, }; } diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 0215558aab..f2aaa11d93 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -114,6 +114,7 @@ const ServerMediaTypesResponseSchema = z const ServerConfigSchema = z .object({ oauthButtonText: z.string().describe('OAuth button text'), + oauthAccountManagementUrl: z.string().describe('OAuth account management URL').optional().default(''), loginPageMessage: z.string().describe('Login page message'), trashDays: z.int().describe('Number of days before trashed assets are permanently deleted'), userDeleteDelay: z.int().describe('Delay in days before deleted users are permanently removed'), diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 6763f6ab74..d22875f286 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -3,7 +3,7 @@ import { SharedLink } from 'src/database'; import { HistoryBuilder } from 'src/decorators'; import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto'; import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto'; -import { SharedLinkTypeSchema } from 'src/enum'; +import { SharedLinkType, SharedLinkTypeSchema } from 'src/enum'; import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; @@ -31,6 +31,29 @@ const SharedLinkCreateSchema = z allowDownload: z.boolean().default(true).optional().describe('Allow downloads'), showMetadata: z.boolean().default(true).optional().describe('Show metadata'), }) + .superRefine(({ type, albumId, assetIds }, ctx) => { + switch (type) { + case SharedLinkType.Album: { + if (!albumId) { + ctx.addIssue(`albumId is required for type ${SharedLinkType.Album}`); + } + if (assetIds) { + ctx.addIssue(`assetIds can only be used with type ${SharedLinkType.Individual}`); + } + return; + } + case SharedLinkType.Individual: { + if (!assetIds || assetIds.length === 0) { + ctx.addIssue(`assetIds are required for type ${SharedLinkType.Individual}`); + } + + if (albumId) { + ctx.addIssue(`albumId can only be used with type ${SharedLinkType.Album}`); + } + return; + } + } + }) .meta({ id: 'SharedLinkCreateDto' }); const SharedLinkEditSchema = z diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts deleted file mode 100644 index a50b7abe87..0000000000 --- a/server/src/dtos/system-config.dto.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { validateCronExpression } from 'cron'; -import { createZodDto } from 'nestjs-zod'; -import { SystemConfig } from 'src/config'; -import { - CLIPConfigSchema, - DuplicateDetectionConfigSchema, - FacialRecognitionConfigSchema, - OcrConfigSchema, -} from 'src/dtos/model-config.dto'; -import { - AudioCodecSchema, - ColorspaceSchema, - CQModeSchema, - HlsVideoResolutionSchema, - ImageFormatSchema, - LogLevelSchema, - OAuthTokenEndpointAuthMethodSchema, - ToneMappingSchema, - TranscodeHardwareAccelerationSchema, - TranscodePolicySchema, - VideoCodecSchema, - VideoContainerSchema, -} from 'src/enum'; -import z from 'zod'; - -/** Coerces 'true'/'false' strings to boolean, but also allows booleans. */ -const configBool = z - .preprocess((val) => { - if (val === 'true') { - return true; - } - if (val === 'false') { - return false; - } - return val; - }, z.boolean()) - .meta({ type: 'boolean' }); - -const JobSettingsSchema = z - .object({ - concurrency: z.int().min(1).describe('Concurrency'), - }) - .meta({ id: 'JobSettingsDto' }); - -const cronExpressionSchema = z - .string() - .superRefine((value, ctx) => { - const validated = validateCronExpression(value); - if (!validated.valid) { - ctx.addIssue({ - code: 'custom', - message: `Invalid cron expression. ${validated.error?.message ?? ''}`, - input: value, - }); - } - }) - .describe('Cron expression'); - -const DatabaseBackupSchema = z - .object({ - enabled: configBool.describe('Enabled'), - cronExpression: cronExpressionSchema, - keepLastAmount: z.int().min(1).describe('Keep last amount'), - }) - .meta({ id: 'DatabaseBackupConfig' }); - -const SystemConfigIntegrityJobSchema = z - .object({ - enabled: z.boolean().describe('Enabled'), - cronExpression: cronExpressionSchema.describe('Cron expression for when the integrity check should run'), - }) - .describe('Integrity job config') - .meta({ id: 'SystemConfigIntegrityJob' }); - -const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({ - timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'), - percentageLimit: z - .float32() - .nonnegative() - .max(1) - .describe('Percentage limit of the integrity checksum job') - .meta({ format: 'double' }), -}) - .describe('Integrity checksum job config') - .meta({ id: 'SystemConfigIntegrityChecksumJob' }); - -const SystemConfigIntegrityChecksSchema = z - .object({ - missingFiles: SystemConfigIntegrityJobSchema, - untrackedFiles: SystemConfigIntegrityJobSchema, - checksumFiles: SystemConfigIntegrityChecksumJobSchema, - }) - .describe('Integrity checks config') - .meta({ id: 'SystemConfigIntegrityChecks' }); - -const SystemConfigBackupsSchema = z.object({ database: DatabaseBackupSchema }).meta({ id: 'SystemConfigBackupsDto' }); - -const SystemConfigFFmpegSchema = z - .object({ - crf: z.coerce.number().int().min(0).max(51).describe('CRF'), - threads: z.coerce.number().int().min(0).describe('Threads'), - preset: z.string().describe('Preset'), - targetVideoCodec: VideoCodecSchema, - acceptedVideoCodecs: z.array(VideoCodecSchema).describe('Accepted video codecs'), - targetAudioCodec: AudioCodecSchema, - acceptedAudioCodecs: z.array(AudioCodecSchema).describe('Accepted audio codecs'), - acceptedContainers: z.array(VideoContainerSchema).describe('Accepted containers'), - targetResolution: z.string().describe('Target resolution'), - maxBitrate: z.string().describe('Max bitrate'), - bframes: z.coerce.number().int().min(-1).max(16).describe('B-frames'), - refs: z.coerce.number().int().min(0).max(6).describe('References'), - gopSize: z.coerce.number().int().min(0).describe('GOP size'), - temporalAQ: configBool.describe('Temporal AQ'), - cqMode: CQModeSchema, - twoPass: configBool.describe('Two pass'), - preferredHwDevice: z.string().describe('Preferred hardware device'), - transcode: TranscodePolicySchema, - accel: TranscodeHardwareAccelerationSchema, - accelDecode: configBool.describe('Accelerated decode'), - tonemap: ToneMappingSchema, - realtime: z - .object({ - enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'), - videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'), - resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'), - }) - .meta({ id: 'SystemConfigFFmpegRealtimeDto' }), - }) - .meta({ id: 'SystemConfigFFmpegDto' }); - -const SystemConfigJobSchema = z - .object({ - thumbnailGeneration: JobSettingsSchema, - metadataExtraction: JobSettingsSchema, - videoConversion: JobSettingsSchema, - faceDetection: JobSettingsSchema, - smartSearch: JobSettingsSchema, - backgroundTask: JobSettingsSchema, - migration: JobSettingsSchema, - search: JobSettingsSchema, - sidecar: JobSettingsSchema, - library: JobSettingsSchema, - notifications: JobSettingsSchema, - ocr: JobSettingsSchema, - workflow: JobSettingsSchema, - editor: JobSettingsSchema, - integrityCheck: JobSettingsSchema, - }) - .meta({ id: 'SystemConfigJobDto' }); - -const SystemConfigLibraryScanSchema = z - .object({ - enabled: configBool.describe('Enabled'), - cronExpression: cronExpressionSchema, - }) - .meta({ id: 'SystemConfigLibraryScanDto' }); - -const SystemConfigLibraryWatchSchema = z - .object({ enabled: configBool.describe('Enabled') }) - .meta({ id: 'SystemConfigLibraryWatchDto' }); - -const SystemConfigLibrarySchema = z - .object({ scan: SystemConfigLibraryScanSchema, watch: SystemConfigLibraryWatchSchema }) - .meta({ id: 'SystemConfigLibraryDto' }); - -const SystemConfigLoggingSchema = z - .object({ - enabled: configBool.describe('Enabled'), - level: LogLevelSchema, - }) - .meta({ id: 'SystemConfigLoggingDto' }); - -const MachineLearningAvailabilityChecksSchema = z - .object({ - enabled: configBool.describe('Enabled'), - timeout: z.int(), - interval: z.int(), - }) - .meta({ id: 'MachineLearningAvailabilityChecksDto' }); - -const SystemConfigMachineLearningSchema = z - .object({ - enabled: configBool.describe('Enabled'), - urls: z.array(z.string()).min(1).describe('ML service URLs'), - availabilityChecks: MachineLearningAvailabilityChecksSchema, - clip: CLIPConfigSchema, - duplicateDetection: DuplicateDetectionConfigSchema, - facialRecognition: FacialRecognitionConfigSchema, - ocr: OcrConfigSchema, - }) - .meta({ id: 'SystemConfigMachineLearningDto' }); - -const SystemConfigMapSchema = z - .object({ - enabled: configBool.describe('Enabled'), - lightStyle: z.url().describe('Light map style URL'), - darkStyle: z.url().describe('Dark map style URL'), - }) - .meta({ id: 'SystemConfigMapDto' }); - -export enum ReleaseChannel { - Stable = 'stable', - ReleaseCandidate = 'releaseCandidate', -} - -const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' }); - -const SystemConfigNewVersionCheckSchema = z - .object({ enabled: configBool.describe('Enabled'), channel: ReleaseChannelSchema }) - .meta({ id: 'SystemConfigNewVersionCheckDto' }); - -const SystemConfigNightlyTasksSchema = z - .object({ - startTime: z.iso - .time({ - precision: -1, - error: (iss) => `Invalid input: expected string in HH:MM format, received ${typeof iss.input}`, - }) - .describe('Start time (HH:MM)'), - databaseCleanup: configBool.describe('Database cleanup'), - missingThumbnails: configBool.describe('Missing thumbnails'), - clusterNewFaces: configBool.describe('Cluster new faces'), - generateMemories: configBool.describe('Generate memories'), - syncQuotaUsage: configBool.describe('Sync quota usage'), - }) - .meta({ id: 'SystemConfigNightlyTasksDto' }); - -const SystemConfigOAuthSchema = z - .object({ - autoLaunch: configBool.describe('Auto launch'), - autoRegister: configBool.describe('Auto register'), - buttonText: z.string().describe('Button text'), - clientId: z.string().describe('Client ID'), - clientSecret: z.string().describe('Client secret'), - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema, - timeout: z.int().min(1).describe('Timeout'), - allowInsecureRequests: configBool.describe('Allow insecure requests'), - defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'), - enabled: configBool.describe('Enabled'), - issuerUrl: z - .string() - .refine((url) => url.length === 0 || z.url().safeParse(url).success, { - error: 'Issuer URL must be an empty string or a valid URL', - }) - .describe('Issuer URL'), - scope: z.string().describe('Scope'), - prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'), - endSessionEndpoint: z - .string() - .refine((url) => url.length === 0 || z.url().safeParse(url).success, { - error: 'endSessionEndpoint must be an empty string or a valid URL', - }) - .describe('End session endpoint'), - signingAlgorithm: z.string().describe('Signing algorithm'), - profileSigningAlgorithm: z.string().describe('Profile signing algorithm'), - storageLabelClaim: z.string().describe('Storage label claim'), - storageQuotaClaim: z.string().describe('Storage quota claim'), - roleClaim: z.string().describe('Role claim'), - mobileOverrideEnabled: configBool.describe('Mobile override enabled'), - mobileRedirectUri: z.string().describe('Mobile redirect URI (set to empty string to disable)'), - }) - .transform((value, ctx) => { - if (!value.mobileOverrideEnabled || value.mobileRedirectUri === '') { - return value; - } - - if (!z.url().safeParse(value.mobileRedirectUri).success) { - ctx.issues.push({ - code: 'custom', - message: 'Mobile redirect URI must be an empty string or a valid URL', - input: value.mobileRedirectUri, - }); - return z.NEVER; - } - - return value; - }) - .meta({ - id: 'SystemConfigOAuthDto', - }); - -const SystemConfigPasswordLoginSchema = z - .object({ enabled: configBool.describe('Enabled') }) - .meta({ id: 'SystemConfigPasswordLoginDto' }); - -const SystemConfigReverseGeocodingSchema = z - .object({ enabled: configBool.describe('Enabled') }) - .meta({ id: 'SystemConfigReverseGeocodingDto' }); - -const SystemConfigFacesSchema = z - .object({ import: configBool.describe('Import') }) - .meta({ id: 'SystemConfigFacesDto' }); -const SystemConfigMetadataSchema = z.object({ faces: SystemConfigFacesSchema }).meta({ id: 'SystemConfigMetadataDto' }); - -const SystemConfigServerSchema = z - .object({ - externalDomain: z - .string() - .refine((url) => url.length === 0 || z.url().safeParse(url).success, { - error: 'External domain must be an empty string or a valid URL', - }) - .describe('External domain'), - loginPageMessage: z.string().describe('Login page message'), - publicUsers: configBool.describe('Public users'), - }) - .meta({ id: 'SystemConfigServerDto' }); - -const SystemConfigSmtpTransportSchema = z - .object({ - ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'), - host: z.string().describe('SMTP server hostname'), - port: z.int().min(0).max(65_535).describe('SMTP server port'), - secure: configBool.describe('Whether to use secure connection (TLS/SSL)'), - username: z.string().describe('SMTP username'), - password: z.string().describe('SMTP password'), - }) - .meta({ id: 'SystemConfigSmtpTransportDto' }); - -const SystemConfigSmtpSchema = z - .object({ - enabled: configBool.describe('Whether SMTP email notifications are enabled'), - from: z.string().describe('Email address to send from'), - replyTo: z.string().describe('Email address for replies'), - transport: SystemConfigSmtpTransportSchema, - }) - .meta({ id: 'SystemConfigSmtpDto' }); - -const SystemConfigNotificationsSchema = z - .object({ smtp: SystemConfigSmtpSchema }) - .meta({ id: 'SystemConfigNotificationsDto' }); - -const SystemConfigTemplateEmailsSchema = z - .object({ - albumInviteTemplate: z.string().describe('Album invite template'), - welcomeTemplate: z.string().describe('Welcome template'), - albumUpdateTemplate: z.string().describe('Album update template'), - }) - .meta({ id: 'SystemConfigTemplateEmailsDto' }); -const SystemConfigTemplatesSchema = z - .object({ email: SystemConfigTemplateEmailsSchema }) - .meta({ id: 'SystemConfigTemplatesDto' }); - -const SystemConfigStorageTemplateSchema = z - .object({ - enabled: configBool.describe('Enabled'), - hashVerificationEnabled: configBool.describe('Hash verification enabled'), - template: z.string().describe('Template'), - }) - .meta({ id: 'SystemConfigStorageTemplateDto' }); - -const SystemConfigTemplateStorageOptionSchema = z - .object({ - yearOptions: z.array(z.string()).describe('Available year format options for storage template'), - monthOptions: z.array(z.string()).describe('Available month format options for storage template'), - weekOptions: z.array(z.string()).describe('Available week format options for storage template'), - dayOptions: z.array(z.string()).describe('Available day format options for storage template'), - hourOptions: z.array(z.string()).describe('Available hour format options for storage template'), - minuteOptions: z.array(z.string()).describe('Available minute format options for storage template'), - secondOptions: z.array(z.string()).describe('Available second format options for storage template'), - presetOptions: z.array(z.string()).describe('Available preset template options'), - }) - .meta({ id: 'SystemConfigTemplateStorageOptionDto' }); - -const SystemConfigThemeSchema = z - .object({ customCss: z.string().describe('Custom CSS for theming') }) - .meta({ id: 'SystemConfigThemeDto' }); - -const SystemConfigGeneratedImageSchema = z - .object({ - format: ImageFormatSchema, - quality: z.int().min(1).max(100).describe('Quality'), - size: z.int().min(1).describe('Size'), - progressive: configBool.default(false).optional().describe('Progressive'), - }) - .meta({ id: 'SystemConfigGeneratedImageDto' }); - -const SystemConfigGeneratedFullsizeImageSchema = z - .object({ - enabled: configBool.describe('Enabled'), - format: ImageFormatSchema, - quality: z.int().min(1).max(100).describe('Quality'), - progressive: configBool.default(false).optional().describe('Progressive'), - }) - .meta({ id: 'SystemConfigGeneratedFullsizeImageDto' }); - -const SystemConfigImageSchema = z - .object({ - thumbnail: SystemConfigGeneratedImageSchema, - preview: SystemConfigGeneratedImageSchema, - fullsize: SystemConfigGeneratedFullsizeImageSchema, - colorspace: ColorspaceSchema, - extractEmbedded: configBool.describe('Extract embedded'), - }) - .meta({ id: 'SystemConfigImageDto' }); - -const SystemConfigTrashSchema = z - .object({ - enabled: configBool.describe('Enabled'), - days: z.int().min(0).describe('Days'), - }) - .meta({ id: 'SystemConfigTrashDto' }); - -const SystemConfigUserSchema = z - .object({ - deleteDelay: z.int().min(1).describe('Delete delay'), - }) - .meta({ id: 'SystemConfigUserDto' }); - -export const SystemConfigSchema = z - .object({ - backup: SystemConfigBackupsSchema, - ffmpeg: SystemConfigFFmpegSchema, - logging: SystemConfigLoggingSchema, - machineLearning: SystemConfigMachineLearningSchema, - map: SystemConfigMapSchema, - newVersionCheck: SystemConfigNewVersionCheckSchema, - nightlyTasks: SystemConfigNightlyTasksSchema, - oauth: SystemConfigOAuthSchema, - passwordLogin: SystemConfigPasswordLoginSchema, - reverseGeocoding: SystemConfigReverseGeocodingSchema, - metadata: SystemConfigMetadataSchema, - storageTemplate: SystemConfigStorageTemplateSchema, - job: SystemConfigJobSchema, - image: SystemConfigImageSchema, - trash: SystemConfigTrashSchema, - theme: SystemConfigThemeSchema, - library: SystemConfigLibrarySchema, - notifications: SystemConfigNotificationsSchema, - templates: SystemConfigTemplatesSchema, - server: SystemConfigServerSchema, - user: SystemConfigUserSchema, - integrityChecks: SystemConfigIntegrityChecksSchema, - }) - .describe('System configuration') - .meta({ id: 'SystemConfigDto' }); - -export class SystemConfigFFmpegDto extends createZodDto(SystemConfigFFmpegSchema) {} -export class SystemConfigSmtpDto extends createZodDto(SystemConfigSmtpSchema) {} -export class SystemConfigTemplateStorageOptionDto extends createZodDto(SystemConfigTemplateStorageOptionSchema) {} -export class SystemConfigDto extends createZodDto(SystemConfigSchema) {} - -export function mapConfig(config: SystemConfig): SystemConfigDto { - return config; -} diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 528163e57c..2a15e43976 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,5 +1,6 @@ import { createZodDto } from 'nestjs-zod'; import { User, UserAdmin } from 'src/database'; +import { HistoryBuilder } from 'src/decorators'; import { pinCodeRegex } from 'src/dtos/auth.dto'; import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum'; import { MaybeDehydrated, UserMetadataItem } from 'src/types'; @@ -116,6 +117,10 @@ const UserAdminDeleteSchema = z export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {} const UserAdminResponseSchema = UserResponseSchema.extend({ + clusterGroupId: z + .uuidv4() + .describe('Cluster group the user is a member of') + .meta(new HistoryBuilder().added('v3.2.0').getExtensions()), storageLabel: z.string().nullable().describe('Storage label'), shouldChangePassword: z.boolean().describe('Require password change on next login'), isAdmin: z.boolean().describe('Is admin user'), @@ -139,6 +144,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { return { ...mapUser(entity), + clusterGroupId: entity.clusterGroupId, storageLabel: entity.storageLabel, shouldChangePassword: entity.shouldChangePassword, isAdmin: entity.isAdmin, diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index b4269ea0c9..990cae79a1 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -1,6 +1,7 @@ import type { WorkflowStepConfig, WorkflowTrigger } from '@immich/plugin-sdk'; import { createZodDto } from 'nestjs-zod'; -import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; +import { WorkflowResultSchema, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; +import { isoDatetimeToDate } from 'src/validation'; import z from 'zod'; const WorkflowTriggerResponseSchema = z @@ -17,6 +18,7 @@ const WorkflowSearchSchema = z name: z.string().optional().describe('Workflow name'), description: z.string().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), + logging: z.boolean().optional().describe('Workflow logs run results'), }) .meta({ id: 'WorkflowSearchDto' }); @@ -42,6 +44,7 @@ const WorkflowCreateSchema = z name: z.string().nullable().optional().describe('Workflow name'), description: z.string().nullable().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), + logging: z.boolean().optional().describe('Workflow logs run results'), steps: z.array(WorkflowStepSchema).optional(), }) .meta({ id: 'WorkflowCreateDto' }); @@ -52,6 +55,7 @@ const WorkflowUpdateSchema = z name: z.string().nullable().optional().describe('Workflow name'), description: z.string().nullable().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), + logging: z.boolean().optional().describe('Workflow logs run results'), steps: z.array(WorkflowStepSchema).optional(), }) .meta({ id: 'WorkflowUpdateDto' }); @@ -65,6 +69,7 @@ const WorkflowResponseSchema = z createdAt: z.string().describe('Creation date'), updatedAt: z.string().describe('Update date'), enabled: z.boolean().describe('Workflow enabled'), + logging: z.boolean().describe('Workflow logs run results'), steps: z.array(WorkflowStepSchema).describe('Workflow steps'), }) .meta({ id: 'WorkflowResponseDto' }); @@ -78,12 +83,36 @@ const WorkflowShareResponseSchema = z }) .meta({ id: 'WorkflowShareResponseDto' }); +const WorkflowLogEntrySchema = z + .object({ + id: z.uuidv4().describe('Workflow log entry ID'), + at: isoDatetimeToDate.describe('Workflow run date/time'), + result: WorkflowResultSchema.describe('Workflow run result'), + triggerDataId: z.uuid().optional().describe('Workflow trigger data ID'), + lastStep: z + .object({ + method: z.string().describe('Method of the step'), + index: z.int().positive().describe('Index of the step in the workflow'), + }) + .optional() + .describe('Last step ran, if the workflow ended early'), + }) + .meta({ id: 'WorkflowLogEntryDto' }); + +const WorkflowGetLogsSchema = z.object({ + result: WorkflowResultSchema.optional().describe('Filter by run result'), + before: isoDatetimeToDate.optional().describe('Filter by runs before a date/time'), + limit: z.coerce.number().int().positive().default(50).describe('Maximum number of logs'), +}); + export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {} export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {} export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {} export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {} export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {} export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {} +export class WorkflowLogEntryDto extends createZodDto(WorkflowLogEntrySchema) {} +export class WorkflowGetLogsDto extends createZodDto(WorkflowGetLogsSchema) {} type Workflow = { id: string; @@ -93,6 +122,7 @@ type Workflow = { name: string | null; description: string | null; enabled: boolean; + logging: boolean; }; type WorkflowStep = { @@ -107,6 +137,7 @@ export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): Wor id: workflow.id, enabled: workflow.enabled, trigger: workflow.trigger, + logging: workflow.logging, name: workflow.name, description: workflow.description, createdAt: workflow.createdAt.toISOString(), diff --git a/server/src/enum.ts b/server/src/enum.ts index 0d29244e09..4be515a9e1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -62,6 +62,8 @@ export enum AssetFileType { EncodedVideo = 'encoded_video', } +export const AssetFileTypeSchema = z.enum(AssetFileType).describe('Type of file').meta({ id: 'AssetFileType' }); + export enum AlbumUserRole { Editor = 'editor', Owner = 'owner', @@ -117,6 +119,7 @@ export enum Permission { ApiKeyRead = 'apiKey.read', ApiKeyUpdate = 'apiKey.update', ApiKeyDelete = 'apiKey.delete', + ApiKeyRotate = 'apiKey.rotate', // ASSET_CREATE = 'asset.create', AssetRead = 'asset.read', @@ -130,6 +133,10 @@ export enum Permission { AssetCopy = 'asset.copy', AssetDerive = 'asset.derive', + AssetFileRead = 'assetFile.read', + AssetFileDelete = 'assetFile.delete', + AssetFileDownload = 'assetFile.download', + AssetEditGet = 'asset.edit.get', AssetEditCreate = 'asset.edit.create', AssetEditDelete = 'asset.edit.delete', @@ -160,6 +167,17 @@ export enum Permission { BackupUpload = 'backup.upload', BackupDelete = 'backup.delete', + ClusterGroupRead = 'clusterGroup.read', + ClusterGroupLeave = 'clusterGroup.leave', + ClusterGroupRequestCreate = 'clusterGroupRequest.create', + ClusterGroupRequestRead = 'clusterGroupRequest.read', + ClusterGroupRequestDelete = 'clusterGroupRequest.delete', + + AdminConfigRead = 'adminConfig.read', + AdminConfigUpdate = 'adminConfig.update', + + UserConfigRead = 'userConfig.read', + DuplicateRead = 'duplicate.read', DuplicateDelete = 'duplicate.delete', @@ -298,6 +316,7 @@ export enum Permission { WorkflowRead = 'workflow.read', WorkflowUpdate = 'workflow.update', WorkflowDelete = 'workflow.delete', + WorkflowLogs = 'workflow.logs', AdminUserCreate = 'adminUser.create', AdminUserRead = 'adminUser.read', @@ -1129,6 +1148,7 @@ export enum NotificationType { SystemMessage = 'SystemMessage', AlbumInvite = 'AlbumInvite', AlbumUpdate = 'AlbumUpdate', + ClusterGroupRequest = 'ClusterGroupRequest', Custom = 'Custom', } @@ -1163,12 +1183,25 @@ export const AssetVisibilitySchema = z .describe('Asset visibility') .meta({ id: 'AssetVisibility' }); +export enum ReleaseChannel { + Stable = 'stable', + ReleaseCandidate = 'releaseCandidate', +} + +export const ReleaseChannelSchema = z.enum(ReleaseChannel).describe('Release channel').meta({ id: 'ReleaseChannel' }); + export enum CronJob { LibraryScan = 'LibraryScan', NightlyJobs = 'NightlyJobs', VersionCheck = 'VersionCheck', } +export enum ConfigVisibility { + Public = 'Public', + User = 'User', + Admin = 'Admin', +} + export enum ApiTag { Activities = 'Activities', Albums = 'Albums', @@ -1176,6 +1209,10 @@ export enum ApiTag { Authentication = 'Authentication', AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', + AssetFiles = 'Asset files', + ConfigUser = 'Config (user)', + ConfigAdmin = 'Config (admin)', + ConfigPublic = 'Config (public)', DatabaseBackups = 'Database Backups (admin)', Deprecated = 'Deprecated', Download = 'Download', @@ -1189,6 +1226,7 @@ export enum ApiTag { Memories = 'Memories', Notifications = 'Notifications', NotificationsAdmin = 'Notifications (admin)', + ClusterGroups = 'Cluster groups', Partners = 'Partners', People = 'People', Plugins = 'Plugins', @@ -1210,14 +1248,6 @@ export enum ApiTag { Workflows = 'Workflows', } -export enum PluginContext { - Asset = 'asset', - Album = 'album', - Person = 'person', -} - -export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' }); - export const WorkflowTriggerSchema = z .enum(WorkflowTrigger) .describe('Plugin trigger type') @@ -1235,6 +1265,17 @@ export enum CalendarHeatmapType { Taken = 'Taken', } +export enum WorkflowResult { + Completed = 'completed', + Halted = 'halted', + Error = 'error', +} + +export const WorkflowResultSchema = z + .enum(WorkflowResult) + .describe('Workflow run result') + .meta({ id: 'WorkflowResult' }); + export enum SearchOrderField { FileCreatedAt = 'fileCreatedAt', LocalDateTime = 'localDateTime', diff --git a/server/src/maintenance/maintenance-auth.guard.ts b/server/src/maintenance/maintenance-auth.guard.ts index 08aaad516b..e2c472ff71 100644 --- a/server/src/maintenance/maintenance-auth.guard.ts +++ b/server/src/maintenance/maintenance-auth.guard.ts @@ -1,11 +1,4 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - SetMetadata, - applyDecorators, - createParamDecorator, -} from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable, SetMetadata, applyDecorators } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; @@ -22,14 +15,6 @@ export interface MaintenanceAuthRequest extends Request { auth?: MaintenanceAuthDto; } -export interface MaintenanceAuthenticatedRequest extends Request { - auth: MaintenanceAuthDto; -} - -export const MaintenanceAuth = createParamDecorator((data, context: ExecutionContext): MaintenanceAuthDto => { - return context.switchToHttp().getRequest().auth; -}); - @Injectable() export class MaintenanceAuthGuard implements CanActivate { constructor( diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 94d3b4d003..d21941f126 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -149,6 +149,17 @@ where "albumAssets"."livePhotoVideoId" ] && array[$2]::uuid[] +-- AccessRepository.assetFile.checkOwnerAccess +select + "asset_file"."id" +from + "asset_file" + inner join "asset" on "asset"."id" = "asset_file"."assetId" +where + "asset"."visibility" != $1 + and "asset"."ownerId" = $2 + and "asset_file"."id" in ($3) + -- AccessRepository.authDevice.checkOwnerAccess select "session"."id" @@ -187,13 +198,56 @@ where "notification"."id" in ($1) and "notification"."userId" = $2 +-- AccessRepository.clusterGroup.checkInviteAccess +select + "cluster_group_request"."clusterGroupId" +from + "cluster_group_request" +where + "cluster_group_request"."clusterGroupId" in ($1) + and "cluster_group_request"."userId" = $2 + +-- AccessRepository.clusterGroup.checkOwnerAccess +select + "user"."clusterGroupId" +from + "user" +where + "user"."clusterGroupId" in ($1) + and "user"."id" = $2 + +-- AccessRepository.clusterGroupRequest.checkOwnerAccess +select + "cluster_group_request"."id" +from + "cluster_group_request" +where + "cluster_group_request"."id" in ($1) + and "cluster_group_request"."userId" = $2 + +-- AccessRepository.clusterGroupRequest.checkGroupAccess +select + "cluster_group_request"."id" +from + "cluster_group_request" +where + "cluster_group_request"."id" in ($1) + and "cluster_group_request"."clusterGroupId" = ( + select + "user"."clusterGroupId" + from + "user" + where + "user"."id" = $2 + ) + -- AccessRepository.person.checkOwnerAccess select - "person"."id" + "person"."personGroupId" from "person" where - "person"."id" in ($1) + "person"."personGroupId" in ($1) and "person"."ownerId" = $2 -- AccessRepository.person.checkFaceOwnerAccess diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index aa04603913..96af89edb5 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -354,9 +354,11 @@ select "asset_file"."assetId" = "asset"."id" and "asset_file"."type" = $2 ) as agg - ) as "files" + ) as "files", + "user"."clusterGroupId" from "asset" + inner join "user" on "user"."id" = "asset"."ownerId" where "asset"."id" = $3 diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index cc694dd63a..dbdae444db 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -192,7 +192,8 @@ select from "person" where - "asset_face"."personId" = "person"."id" + "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = "asset"."ownerId" ) as "person" on true where "asset_face"."assetId" = "asset"."id" @@ -530,7 +531,7 @@ where and "libraryId" = $5::uuid and ( not "originalPath" like $6 - or "originalPath" like $7 + or "originalPath" ~ $7 ) -- AssetRepository.filterNewExternalAssetPaths diff --git a/server/src/queries/cluster.group.repository.sql b/server/src/queries/cluster.group.repository.sql new file mode 100644 index 0000000000..f81e87a6a5 --- /dev/null +++ b/server/src/queries/cluster.group.repository.sql @@ -0,0 +1,71 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- ClusterGroupRepository.create +insert into + "cluster_group" +default values +returning + * + +-- ClusterGroupRepository.hasOtherMembers +select + "user"."id" +from + "user" +where + "user"."clusterGroupId" = $1 + and "user"."id" != $2 + and "user"."deletedAt" is null + +-- ClusterGroupRepository.createRequest +insert into + "cluster_group_request" ("clusterGroupId", "userId") +values + ($1, $2) +on conflict ("clusterGroupId", "userId") do update +set + "clusterGroupId" = $3 +returning + *, + (xmax = 0) as "isInserted" + +-- ClusterGroupRepository.getRequest +select + "cluster_group_request".* +from + "cluster_group_request" +where + "cluster_group_request"."id" = $1 + +-- ClusterGroupRepository.searchRequests +select + "cluster_group_request".* +from + "cluster_group_request" +where + "cluster_group_request"."userId" = $1 + and "cluster_group_request"."clusterGroupId" = $2 +order by + "cluster_group_request"."createdAt" asc + +-- ClusterGroupRepository.getUsers +select + "id", + "name", + "email", + "avatarColor", + "profileImagePath", + "profileChangedAt" +from + "user" +where + "user"."clusterGroupId" = $1 + and "user"."deletedAt" is null +order by + "user"."id" = $2 desc, + "user"."name" asc + +-- ClusterGroupRepository.deleteRequest +delete from "cluster_group_request" +where + "cluster_group_request"."id" = $1 diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 44339cbcd9..7730813a5f 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -47,7 +47,8 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = "asset"."ownerId" where "asset_face"."assetId" = "asset"."id" and "person"."isHidden" = $2 @@ -86,7 +87,8 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person" on "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = "asset"."ownerId" where "asset_face"."assetId" = "asset"."id" and "person"."isHidden" = $2 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index a2f3f64442..aa17fd05fa 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -3,18 +3,53 @@ -- PersonRepository.reassignFaces update "asset_face" set - "personId" = $1 + "personGroupId" = $1 where - "asset_face"."personId" = $2 + "asset_face"."personGroupId" = $2 -- PersonRepository.delete delete from "person" where - "person"."id" in ($1) + "ownerId" = $1 + and "person"."personGroupId" in ($2) +returning + "personGroupId", + "ownerId", + "thumbnailPath" + +-- PersonRepository.deleteGroups +delete from "person_group" +where + "person_group"."id" in ($1) + +-- PersonRepository.deleteEmptyGroups +delete from "person_group" +where + not exists ( + select + "person"."personGroupId" + from + "person" + where + "person"."personGroupId" = "person_group"."id" + ) + +-- PersonRepository.deleteOrphanedClusterGroups +delete from "cluster_group" +where + not exists ( + select + "user"."id" + from + "user" + where + "user"."clusterGroupId" = "cluster_group"."id" + ) -- PersonRepository.getFileSamples select - "id", + "ownerId", + "personGroupId", "thumbnailPath" from "person" @@ -28,8 +63,9 @@ select "person".* from "person" - inner join "asset_face" on "asset_face"."personId" = "person"."id" + inner join "asset_face" on "asset_face"."personGroupId" = "person"."personGroupId" inner join "asset" on "asset_face"."assetId" = "asset"."id" + and "asset"."ownerId" = "person"."ownerId" and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null where @@ -38,7 +74,8 @@ where and "asset_face"."isVisible" is true and "person"."isHidden" = $2 group by - "person"."id" + "person"."ownerId", + "person"."personGroupId" having ( "person"."name" != $3 @@ -72,12 +109,13 @@ select "person".* from "person" - left join "asset_face" on "asset_face"."personId" = "person"."id" + left join "asset_face" on "asset_face"."personGroupId" = "person"."personGroupId" where "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true group by - "person"."id" + "person"."ownerId", + "person"."personGroupId" having count("asset_face"."assetId") = $1 @@ -94,15 +132,16 @@ select from "person" where - "person"."id" = "asset_face"."personId" + "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."assetId" = $1 + "asset_face"."assetId" = $2 and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 + and "asset_face"."isVisible" = $3 order by "asset_face"."boundingBoxX1" asc @@ -119,19 +158,20 @@ select from "person" where - "person"."id" = "asset_face"."personId" + "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."id" = $1 + "asset_face"."id" = $2 and "asset_face"."deletedAt" is null -- PersonRepository.getFaceForFacialRecognitionJob select "asset_face"."id", - "asset_face"."personId", + "asset_face"."personGroupId", "asset_face"."sourceType", ( select @@ -141,9 +181,11 @@ select select "asset"."ownerId", "asset"."visibility", - "asset"."fileCreatedAt" + "asset"."fileCreatedAt", + "user"."clusterGroupId" from "asset" + inner join "user" on "user"."id" = "asset"."ownerId" where "asset"."id" = "asset_face"."assetId" ) as obj @@ -195,16 +237,26 @@ from inner join "asset" on "asset_face"."assetId" = "asset"."id" left join "asset_exif" on "asset_exif"."assetId" = "asset"."id" where - "person"."id" = $1 + "person"."ownerId" = $1 + and "person"."personGroupId" = $2 and "asset_face"."deletedAt" is null -- PersonRepository.reassignFace update "asset_face" set - "personId" = $1 + "personGroupId" = $1 where "asset_face"."id" = $2 +-- PersonRepository.getByGroupId +select + "person".* +from + "person" +where + "person"."personGroupId" = $1 + and "person"."ownerId" = $2 + -- PersonRepository.getByName with "similarity_threshold" as ( @@ -226,7 +278,7 @@ limit -- PersonRepository.getDistinctNames select distinct - on (lower("person"."name")) "person"."id", + on (lower("person"."name")) "person"."personGroupId", "person"."name" from "person" @@ -244,10 +296,25 @@ from left join "asset" on "asset"."id" = "asset_face"."assetId" and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null + and ( + "asset"."ownerId" = $1::uuid + or exists ( + select + 1 as "exists" + from + "album_asset" + inner join "album" on "album"."id" = "album_asset"."albumId" + and "album"."deletedAt" is null + inner join "album_user" on "album_user"."albumId" = "album"."id" + and "album_user"."userId" = $2::uuid + where + "album_asset"."assetId" = "asset"."id" + ) + ) where "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "asset_face"."personId" = $1 + and "asset_face"."personGroupId" = $3 -- PersonRepository.getNumberOfPeople select @@ -267,7 +334,7 @@ where from "asset_face" where - "asset_face"."personId" = "person"."id" + "asset_face"."personGroupId" = "person"."personGroupId" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" = $2 and exists ( @@ -282,6 +349,164 @@ where ) and "person"."ownerId" = $3 +-- PersonRepository.createGroup +insert into + "person_group" ("clusterGroupId") +select + "user"."clusterGroupId" +from + "user" +where + "user"."id" = $1 +returning + * + +-- PersonRepository.reassignCluster +begin +update "person_group" +set + "clusterGroupId" = $1 +where + "person_group"."id" in ( + select + "person"."personGroupId" + from + "person" + where + "person"."ownerId" = $2 + ) + and not exists ( + select + "person"."personGroupId" + from + "person" + where + "person"."personGroupId" = "person_group"."id" + and "person"."ownerId" != $3 + ) +with + "shared" as ( + select distinct + "person"."personGroupId" as "oldId" + from + "person" + where + "person"."ownerId" = $1 + and exists ( + select + "other"."personGroupId" + from + "person" as "other" + where + "other"."personGroupId" = "person"."personGroupId" + and "other"."ownerId" != $2 + ) + ), + "mapping" as materialized ( + select + "shared"."oldId", + uuid_generate_v4 () as "newId" + from + "shared" + ), + "created" as ( + insert into + "person_group" ("id", "clusterGroupId") + select + "mapping"."newId", + $3 as "clusterGroupId" + from + "mapping" + ) +select + "mapping"."oldId", + "mapping"."newId" +from + "mapping" +commit + +-- PersonRepository.createGroups +insert into + "person_group" ( + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "34", + "35" + ) +values + ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16, + $17, + $18, + $19, + $20, + $21, + $22, + $23, + $24, + $25, + $26, + $27, + $28, + $29, + $30, + $31, + $32, + $33, + $34, + $35, + $36 + ) +returning + * + -- PersonRepository.refreshFaces with "added_embeddings" as ( @@ -310,14 +535,15 @@ select from "person" where - "person"."id" = "asset_face"."personId" + "person"."personGroupId" = "asset_face"."personGroupId" + and "person"."ownerId" = $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."assetId" in ($1) - and "asset_face"."personId" in ($2) + "asset_face"."assetId" in ($2) + and "asset_face"."personGroupId" in ($3) and "asset_face"."deletedAt" is null -- PersonRepository.getRandomFace @@ -326,7 +552,7 @@ select from "asset_face" where - "asset_face"."personId" = $1 + "asset_face"."personGroupId" = $1 and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true @@ -348,15 +574,6 @@ set where "asset_face"."id" = $2 --- PersonRepository.getForPeopleDelete -select - "id", - "thumbnailPath" -from - "person" -where - "id" in ($1) - -- PersonRepository.getForFeatureFaceUpdate select "asset_face"."id" @@ -366,4 +583,14 @@ from and "asset"."isOffline" = $1 where "asset_face"."assetId" = $2 - and "asset_face"."personId" = $3 + and "asset_face"."personGroupId" = $3 + +-- PersonRepository.getForMergePerson +select + "person".* +from + "person" +where + "person"."personGroupId" in ($1) +order by + "person"."ownerId" diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index efd7236bb5..abd10e6221 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -218,15 +218,21 @@ with "cte" as ( select "asset_face"."id", - "asset_face"."personId", + "asset_face"."personGroupId", face_search.embedding <=> $1 as "distance" from "asset_face" inner join "asset" on "asset"."id" = "asset_face"."assetId" inner join "face_search" on "face_search"."faceId" = "asset_face"."id" - left join "person" on "person"."id" = "asset_face"."personId" where - "asset"."ownerId" = any ($2::uuid[]) + "asset"."ownerId" in ( + select + "user"."id" + from + "user" + where + "user"."clusterGroupId" = $2 + ) and "asset"."deletedAt" is null order by "distance" @@ -239,7 +245,7 @@ from "cte" where "cte"."distance" <= $4 -commit +rollback -- SearchRepository.searchPlaces select @@ -849,11 +855,11 @@ where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" = $2 - and "asset_face"."personId" = any ($3::uuid[]) + and "asset_face"."personGroupId" = any ($3::uuid[]) group by "asset_face"."assetId" having - count(distinct "asset_face"."personId") = $4 + count(distinct "asset_face"."personGroupId") = $4 ) order by "asset"."fileCreatedAt" desc, @@ -1374,7 +1380,7 @@ where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" = $3 - and "asset_face"."personId" = any ($4::uuid[]) + and "asset_face"."personGroupId" = any ($4::uuid[]) ) ) order by diff --git a/server/src/queries/stack.repository.sql b/server/src/queries/stack.repository.sql index b5f1dc7d18..88e21d653e 100644 --- a/server/src/queries/stack.repository.sql +++ b/server/src/queries/stack.repository.sql @@ -54,6 +54,8 @@ select "asset"."deletedAt" is null and "asset"."stackId" = "stack"."id" and "asset"."visibility" in ('archive', 'timeline') + order by + "asset"."fileCreatedAt" asc ) as agg ) as "assets" from @@ -139,6 +141,8 @@ select "asset"."deletedAt" is null and "asset"."stackId" = "stack"."id" and "asset"."visibility" in ('archive', 'timeline') + order by + "asset"."fileCreatedAt" asc ) as agg ) as "assets" from diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 69f7981bf3..15c5a73f2c 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -536,7 +536,7 @@ order by select "asset_face"."id", "assetId", - "personId", + "personGroupId" as "personId", "imageWidth", "imageHeight", "boundingBoxX1", @@ -1029,7 +1029,7 @@ order by -- SyncRepository.person.getDeletes select "id", - "personId" + "personGroupId" as "personId" from "person_audit" as "person_audit" where @@ -1041,7 +1041,7 @@ order by -- SyncRepository.person.getUpserts select - "id", + "personGroupId" as "id", "createdAt", "updatedAt", "ownerId", diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index c5a4f139a7..de5cfea138 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -8,6 +8,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "clusterGroupId", "createdAt", "updatedAt", "deletedAt", @@ -47,6 +48,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "clusterGroupId", "createdAt", "updatedAt", "deletedAt", @@ -126,6 +128,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "clusterGroupId", "createdAt", "updatedAt", "deletedAt", @@ -165,6 +168,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "clusterGroupId", "createdAt", "updatedAt", "deletedAt", @@ -190,6 +194,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "clusterGroupId", "createdAt", "updatedAt", "deletedAt", @@ -237,6 +242,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "clusterGroupId", "createdAt", "updatedAt", "deletedAt", @@ -275,6 +281,7 @@ select "avatarColor", "profileImagePath", "profileChangedAt", + "clusterGroupId", "createdAt", "updatedAt", "deletedAt", diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql index f44ba54066..293d053d81 100644 --- a/server/src/queries/workflow.repository.sql +++ b/server/src/queries/workflow.repository.sql @@ -9,6 +9,7 @@ select "workflow"."enabled", "workflow"."createdAt", "workflow"."updatedAt", + "workflow"."logging", ( select coalesce(json_agg(agg), '[]') @@ -43,6 +44,7 @@ select "workflow"."enabled", "workflow"."createdAt", "workflow"."updatedAt", + "workflow"."logging", ( select coalesce(json_agg(agg), '[]') @@ -73,6 +75,7 @@ select "workflow"."id", "workflow"."name", "workflow"."trigger", + "workflow"."logging", ( select coalesce(json_agg(agg), '[]') @@ -100,6 +103,39 @@ where "id" = $2 and "enabled" = $3 +-- WorkflowRepository.getLogs +select + "workflow_log"."id", + "workflow_log"."createdAt", + "workflow_log"."result", + "workflow_log"."workflowId", + "workflow_log"."workflowStepId", + "workflow_log"."triggerDataId", + ( + select + to_json(obj) + from + ( + select + "plugin_method"."pluginId", + "plugin_method"."name" as "methodName", + "workflow_step"."order" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + where + "workflow_step"."id" = "workflow_log"."workflowStepId" + ) as obj + ) as "step" +from + "workflow_log" +where + "workflow_log"."workflowId" = $1 +order by + "workflow_log"."createdAt" desc +limit + $2 + -- WorkflowRepository.delete delete from "workflow" where diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index b315538bf9..9acb8c3fcb 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -278,6 +278,28 @@ class AssetAccess { } } +class AssetFileAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, fileIds: Set, hasElevatedPermission: boolean | undefined) { + if (fileIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('asset_file') + .select('asset_file.id') + .innerJoin('asset', 'asset.id', 'asset_file.assetId') + .$if(!hasElevatedPermission, (eb) => eb.where('asset.visibility', '!=', AssetVisibility.Locked)) + .where('asset.ownerId', '=', userId) + .where('asset_file.id', 'in', [...fileIds]) + .execute() + .then((files) => new Set(files.map(({ id }) => id))); + } +} + class AuthDeviceAccess { constructor(private db: Kysely) {} @@ -420,23 +442,97 @@ class MemoryAccess { } } +class ClusterGroupAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + checkInviteAccess(userId: string, clusterGroupIds: Set) { + if (clusterGroupIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('cluster_group_request') + .select('cluster_group_request.clusterGroupId') + .where('cluster_group_request.clusterGroupId', 'in', [...clusterGroupIds]) + .where('cluster_group_request.userId', '=', userId) + .execute() + .then((requests) => new Set(requests.map((request) => request.clusterGroupId))); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, clusterGroupIds: Set) { + if (clusterGroupIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('user') + .select('user.clusterGroupId') + .where('user.clusterGroupId', 'in', [...clusterGroupIds]) + .where('user.id', '=', userId) + .execute() + .then((users) => new Set(users.map((user) => user.clusterGroupId))); + } +} + +class ClusterGroupRequestAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + checkOwnerAccess(userId: string, clusterGroupRequestIds: Set) { + if (clusterGroupRequestIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('cluster_group_request') + .select('cluster_group_request.id') + .where('cluster_group_request.id', 'in', [...clusterGroupRequestIds]) + .where('cluster_group_request.userId', '=', userId) + .execute() + .then((requests) => new Set(requests.map(({ id }) => id))); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + checkGroupAccess(userId: string, clusterGroupRequestIds: Set) { + if (clusterGroupRequestIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('cluster_group_request') + .select('cluster_group_request.id') + .where('cluster_group_request.id', 'in', [...clusterGroupRequestIds]) + .where('cluster_group_request.clusterGroupId', '=', (eb) => + eb.selectFrom('user').select('user.clusterGroupId').where('user.id', '=', userId), + ) + .execute() + .then((requests) => new Set(requests.map(({ id }) => id))); + } +} + class PersonAccess { constructor(private db: Kysely) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) - async checkOwnerAccess(userId: string, personIds: Set) { - if (personIds.size === 0) { + async checkOwnerAccess(userId: string, personGroupIds: Set) { + if (personGroupIds.size === 0) { return new Set(); } return this.db .selectFrom('person') - .select('person.id') - .where('person.id', 'in', [...personIds]) + .select('person.personGroupId') + .where('person.personGroupId', 'in', [...personGroupIds]) .where('person.ownerId', '=', userId) .execute() - .then((persons) => new Set(persons.map((person) => person.id))); + .then((persons) => new Set(persons.map((person) => person.personGroupId))); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @@ -522,10 +618,13 @@ export class AccessRepository { activity: ActivityAccess; album: AlbumAccess; asset: AssetAccess; + assetFile: AssetFileAccess; authDevice: AuthDeviceAccess; duplicate: DuplicateAccess; memory: MemoryAccess; notification: NotificationAccess; + clusterGroup: ClusterGroupAccess; + clusterGroupRequest: ClusterGroupRequestAccess; person: PersonAccess; partner: PartnerAccess; session: SessionAccess; @@ -538,10 +637,13 @@ export class AccessRepository { this.activity = new ActivityAccess(db); this.album = new AlbumAccess(db); this.asset = new AssetAccess(db); + this.assetFile = new AssetFileAccess(db); this.authDevice = new AuthDeviceAccess(db); this.duplicate = new DuplicateAccess(db); this.memory = new MemoryAccess(db); this.notification = new NotificationAccess(db); + this.clusterGroup = new ClusterGroupAccess(db); + this.clusterGroupRequest = new ClusterGroupRequestAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); this.session = new SessionAccess(db); diff --git a/server/src/repositories/asset-file.repository.ts b/server/src/repositories/asset-file.repository.ts new file mode 100644 index 0000000000..ac0f51e60a --- /dev/null +++ b/server/src/repositories/asset-file.repository.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class AssetFileRepository { + constructor(@InjectKysely() private db: Kysely) {} + + get(id: string) { + return this.db.selectFrom('asset_file').where('id', '=', id).selectAll().executeTakeFirst(); + } + + search(dto: AssetFileSearchDto) { + return this.db + .selectFrom('asset_file') + .where('assetId', '=', dto.assetId) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.isEdited !== undefined, (qb) => qb.where('isEdited', '=', dto.isEdited!)) + .$if(dto.isProgressive !== undefined, (qb) => qb.where('isProgressive', '=', dto.isProgressive!)) + .$if(dto.isTransparent !== undefined, (qb) => qb.where('isTransparent', '=', dto.isTransparent!)) + .selectAll() + .execute(); + } + + async delete(id: string): Promise { + const { numDeletedRows } = await this.db.deleteFrom('asset_file').where('id', '=', id).executeTakeFirst(); + return Number(numDeletedRows) === 1; + } +} diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index bab0c44a41..5c7924a208 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -151,6 +151,8 @@ export class AssetJobRepository { .select(columns.asset) .select(withFaces) .select((eb) => withFiles(eb, AssetFileType.Sidecar)) + .innerJoin('user', 'user.id', 'asset.ownerId') + .select(['user.clusterGroupId']) .where('asset.id', '=', id) .executeTakeFirst(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index add3f0a24d..189c5c621f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -37,6 +37,7 @@ import { anyUuid, asUuid, hasPeople, + inSharedAlbum, removeUndefinedKeys, truncatedDate, unnest, @@ -53,7 +54,7 @@ import { withTagId, withTags, } from 'src/utils/database'; -import { globToSqlPattern } from 'src/utils/misc'; +import { globToPostgresRegex } from 'src/utils/misc'; export type AssetStats = Record; @@ -124,7 +125,7 @@ interface AssetGetByChecksumOptions { interface GetByIdsRelations { exifInfo?: boolean; - faces?: { person?: boolean; withDeleted?: boolean }; + faces?: { person?: boolean; withDeleted?: boolean; viewingUserId?: string }; files?: boolean; library?: boolean; owner?: boolean; @@ -448,6 +449,9 @@ export class AssetRepository { @ChunkedArray({ chunkSize: 4000 }) async createAll(assets: Insertable[]) { + if (assets.length === 0) { + return []; + } const ids = await this.db.insertInto('asset').values(assets).returning('id').execute(); return ids.map(({ id }) => id); } @@ -510,12 +514,12 @@ export class AssetRepository { } @GenerateSql({ params: [[DummyValue.UUID]] }) - @ChunkedArray() - getByIdsWithAllRelationsButStacks(ids: string[]) { + @ChunkedArray({ paramIndex: 0 }) + getByIdsWithAllRelationsButStacks(ids: string[], viewingUserId?: string) { return this.db .selectFrom('asset') .selectAll('asset') - .select(withFacesAndPeople) + .select(withFacesAndPeople({ viewingUserId })) .select(withTags) .$call(withExif) .where('asset.id', '=', anyUuid(ids)) @@ -574,7 +578,11 @@ export class AssetRepository { .selectAll('asset') .where('asset.id', '=', asUuid(id)) .$if(!!exifInfo, withExif) - .$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>()) + .$if(!!faces, (qb) => + qb + .select(faces?.person ? withFacesAndPeople({ viewingUserId: faces.viewingUserId! }) : withFaces) + .$narrowType<{ faces: NotNull }>(), + ) .$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!owner, (qb) => qb.select(withOwner)) .$if(!!smartSearch, withSmartSearch) @@ -591,10 +599,10 @@ export class AssetRepository { eb .selectFrom('asset as stacked') .selectAll('stack') - .select((eb) => - eb - .fn>>('array_agg', [eb.table('stacked')]) - .as('assets'), + .select( + sql< + ShallowDehydrateObject>[] + >`array_agg(to_json(stacked) ORDER BY stacked."fileCreatedAt" ASC)`.as('assets'), ) .whereRef('stacked.stackId', '=', 'stack.id') .whereRef('stacked.id', '!=', 'stack.primaryAssetId') @@ -636,12 +644,12 @@ export class AssetRepository { .selectFrom('asset') .selectAll('asset') .$call(withExif) - .$call((qb) => qb.select(withFacesAndPeople)) + .$call((qb) => qb.select(withFaces)) .$call((qb) => qb.select(withEdits)) .executeTakeFirst(); } - return this.getById(asset.id, { exifInfo: true, faces: { person: true }, edits: true }); + return this.getById(asset.id, { exifInfo: true, faces: {}, edits: true }); } async remove(asset: { id: string }): Promise { @@ -740,8 +748,8 @@ export class AssetRepository { .execute(); } - @GenerateSql({ params: [{}] }) - async getTimeBuckets(options: TimeBucketOptions): Promise { + @GenerateSql({ params: [{}, { user: { id: DummyValue.UUID } }] }) + async getTimeBuckets(options: TimeBucketOptions, auth: AuthDto): Promise { return this.db .with('asset', (qb) => qb @@ -778,7 +786,13 @@ export class AssetRepository { ) .where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])), ) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.userIds, (qb) => + qb.where((eb) => { + // TODO this should become a shared `hasAccess` style helper once implement sharing in more places + const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!)); + return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner; + }), + ) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!)) .$if(options.isDuplicate !== undefined, (qb) => @@ -864,7 +878,12 @@ export class AssetRepository { ), ) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.userIds, (qb) => + qb.where((eb) => { + const isOwner = eb('asset.ownerId', '=', anyUuid(options.userIds!)); + return options.personId ? eb.or([isOwner, inSharedAlbum(eb, auth.user.id)]) : isOwner; + }), + ) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(!!options.withStacked, (qb) => qb @@ -1067,7 +1086,7 @@ export class AssetRepository { exclusionPatterns: string[], ): Promise { const paths = importPaths.map((importPath) => `${importPath}%`); - const exclusions = exclusionPatterns.map((pattern) => globToSqlPattern(pattern)); + const exclusions = exclusionPatterns.map((pattern) => globToPostgresRegex(pattern)); return this.db .updateTable('asset') @@ -1081,7 +1100,7 @@ export class AssetRepository { .where((eb) => eb.or([ eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))), - eb.or(exclusions.map((path) => eb('originalPath', 'like', path))), + eb.or(exclusions.map((pattern) => eb('originalPath', '~', pattern))), ]), ) .executeTakeFirstOrThrow(); diff --git a/server/src/repositories/cluster-group.repository.ts b/server/src/repositories/cluster-group.repository.ts new file mode 100644 index 0000000000..821870b342 --- /dev/null +++ b/server/src/repositories/cluster-group.repository.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { DB } from 'src/schema'; +import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table'; + +@Injectable() +export class ClusterGroupRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql() + create() { + return this.db.insertInto('cluster_group').defaultValues().returningAll().executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] }) + async hasOtherMembers({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }): Promise { + const member = await this.db + .selectFrom('user') + .select('user.id') + .where('user.clusterGroupId', '=', clusterGroupId) + .where('user.id', '!=', userId) + .where('user.deletedAt', 'is', null) + .executeTakeFirst(); + + return !!member; + } + + @GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] }) + createRequest(request: Insertable) { + return this.db + .insertInto('cluster_group_request') + .values(request) + .onConflict((oc) => + // the update is pointless, but required for the query to return the conflicting row + oc.columns(['clusterGroupId', 'userId']).doUpdateSet({ clusterGroupId: request.clusterGroupId }), + ) + .returningAll() + .returning(sql`(xmax = 0)`.as('isInserted')) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getRequest(id: string) { + return this.db + .selectFrom('cluster_group_request') + .selectAll('cluster_group_request') + .where('cluster_group_request.id', '=', id) + .executeTakeFirst(); + } + + @GenerateSql({ params: [{ userId: DummyValue.UUID, clusterGroupId: DummyValue.UUID }] }) + searchRequests({ userId, clusterGroupId }: { userId?: string; clusterGroupId?: string } = {}) { + return this.db + .selectFrom('cluster_group_request') + .selectAll('cluster_group_request') + .$if(!!userId, (qb) => qb.where('cluster_group_request.userId', '=', userId!)) + .$if(!!clusterGroupId, (qb) => qb.where('cluster_group_request.clusterGroupId', '=', clusterGroupId!)) + .orderBy('cluster_group_request.createdAt', 'asc') + .execute(); + } + + @GenerateSql({ params: [{ clusterGroupId: DummyValue.UUID, userId: DummyValue.UUID }] }) + getUsers({ clusterGroupId, userId }: { clusterGroupId: string; userId: string }) { + return this.db + .selectFrom('user') + .select(columns.user) + .where('user.clusterGroupId', '=', clusterGroupId) + .where('user.deletedAt', 'is', null) + .orderBy((eb) => eb('user.id', '=', userId), 'desc') + .orderBy('user.name', 'asc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteRequest(id: string): Promise { + await this.db.deleteFrom('cluster_group_request').where('cluster_group_request.id', '=', id).execute(); + } +} diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 1b5592d356..165752783a 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -483,35 +483,6 @@ export class DatabaseRepository { await sql`SELECT pg_advisory_unlock(${lock})`.execute(connection); } - async revertLastMigration(): Promise { - this.logger.debug('Reverting last migration'); - - const migrator = this.createMigrator(); - const { error, results } = await migrator.migrateDown(); - - for (const result of results ?? []) { - if (result.status === 'Success') { - this.logger.log(`Reverted migration "${result.migrationName}"`); - } else if (result.status === 'Error') { - this.logger.warn(`Failed to revert migration "${result.migrationName}"`); - } - } - - if (error) { - this.logger.error(`Failed to revert migrations: ${error}`); - throw error; - } - - const reverted = results?.find((result) => result.direction === 'Down' && result.status === 'Success'); - if (!reverted) { - this.logger.debug('No migrations to revert'); - return undefined; - } - - this.logger.debug('Finished reverting migration'); - return reverted.migrationName; - } - private createMigrator(): Migrator { return new Migrator({ db: this.db, diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 416f823952..4ce5f7fbdc 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; import { Socket } from 'socket.io'; -import { SystemConfig } from 'src/config'; import { Asset } from 'src/database'; import { EventConfig } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -41,9 +41,12 @@ type EventMap = { AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }]; AlbumInvite: [{ id: string; userId: string; senderName: string }]; + // cluster group events + ClusterGroupRequest: [{ clusterGroupId: string; userId: string; senderName: string }]; + // asset events AssetCreate: [{ asset: Pick; file?: UploadFile }]; - AssetTag: [{ assetId: string }]; + AssetTag: [{ assetId: string; userId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; AssetShow: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 534a71b6d6..606f3b8b57 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,8 +5,10 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; @@ -61,6 +63,7 @@ export const repositories = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ConfigRepository, CronRepository, @@ -83,6 +86,7 @@ export const repositories = [ NotificationRepository, OAuthRepository, OcrRepository, + ClusterGroupRepository, PartnerRepository, PersonRepository, PluginRepository, diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index c9825f31c1..c1b14fae0a 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import { readFile } from 'node:fs/promises'; -import { MachineLearningConfig } from 'src/config'; -import { CLIPConfig } from 'src/dtos/model-config.dto'; +import { MachineLearningConfig } from 'src/dtos/config.dto'; import { LoggingRepository } from 'src/repositories/logging.repository'; export interface BoundingBox { @@ -73,7 +72,6 @@ export interface Face { } export type FacialRecognitionResponse = { [ModelTask.FACIAL_RECOGNITION]: Face[] } & VisualResponse; -export type DetectedFaces = { faces: Face[] } & VisualResponse; export type MachineLearningRequest = ClipVisualRequest | ClipTextualRequest | FacialRecognitionRequest | OcrRequest; export type TextEncodingOptions = ModelOptions & { language?: string }; @@ -206,7 +204,7 @@ export class MachineLearningRepository { }; } - async encodeImage(imagePath: string, { modelName }: CLIPConfig) { + async encodeImage(imagePath: string, { modelName }: MachineLearningConfig['clip']) { const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } }; const response = await this.predict({ imagePath }, request); return response[ModelTask.SEARCH]; diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts index e8106c0ff9..08475b4404 100644 --- a/server/src/repositories/media.repository.spec.ts +++ b/server/src/repositories/media.repository.spec.ts @@ -329,7 +329,7 @@ describe(MediaRepository.name, () => { const baseFace: AssetFace = { id: 'face-1', assetId: 'asset-1', - personId: 'person-1', + personGroupId: 'person-1', boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index c1e8cfd234..1100e856b5 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -187,6 +187,7 @@ export class MediaRepository { let pipeline = sharp(input, { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes failOn: options.processInvalidImages ? 'none' : 'error', + limitInputChannels: false, limitInputPixels: false, raw: options.raw, unlimited: true, diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index b421ba6ec8..da74fb1f4c 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -73,7 +73,11 @@ export class MemoryRepository implements IBulkAsset { eb.exists( eb .selectFrom('asset_face') - .innerJoin('person', 'person.id', 'asset_face.personId') + .innerJoin('person', (join) => + join + .onRef('person.personGroupId', '=', 'asset_face.personGroupId') + .onRef('person.ownerId', '=', 'asset.ownerId'), + ) .select((eb) => eb.val(1).as('one')) .whereRef('asset_face.assetId', '=', 'asset.id') .where('person.isHidden', '=', true), diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 1d504f6c71..c4a3b4f89e 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -92,8 +92,18 @@ export class MetadataRepository { /* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */ geoTz: (lat, lon) => geotz.find(lat, lon)[0], geolocation: true, - // Enable exiftool LFS to parse metadata for files larger than 2GB. - readArgs: ['-api', 'largefilesupport=1', '--ICC_Profile:DeviceManufacturer', '--ICC_Profile:DeviceModelName'], + readArgs: [ + // Enable exiftool LFS to parse metadata for files larger than 2GB. + '-api', + 'largefilesupport=1', + '--ICC_Profile:DeviceManufacturer', + '--ICC_Profile:DeviceModelName', + // Ignore embedded thumbnail dimensions/orientation for the main asset. + '--IFD1:Orientation', + '--MWG:Orientation', + '--IFD1:ImageWidth', + '--IFD1:ImageHeight', + ], writeArgs: ['-api', 'largefilesupport=1', '-overwrite_original'], taskTimeoutMillis: 2 * 60 * 1000, }); diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index 1a09fff70d..0d56343f53 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -22,6 +22,7 @@ export type OAuthConfig = { clientId: string; clientSecret?: string; issuerUrl: string; + accountManagementUrl: string; endSessionEndpoint: string; mobileOverrideEnabled: boolean; mobileRedirectUri: string; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0db03a18c7..28aea85041 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely'; import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; @@ -8,8 +8,9 @@ import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src import { DB } from 'src/schema'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { PersonTable } from 'src/schema/tables/person.table'; -import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database'; +import { asUuid, dummy, inSharedAlbum, removeUndefinedKeys, withFilePath } from 'src/utils/database'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; export interface PersonSearchOptions { @@ -22,19 +23,20 @@ export interface PersonNameSearchOptions { } export interface PersonNameResponse { - id: string; + personGroupId: string; name: string; } export interface AssetFaceId { assetId: string; - personId: string; + personGroupId: string; } export interface UpdateFacesData { - oldPersonId?: string; + oldPersonGroupId?: string; faceIds?: string[]; - newPersonId: string; + ownerId?: string; + newPersonGroupId: string; } export interface PersonStatistics { @@ -53,19 +55,34 @@ export interface GetAllPeopleOptions { } export interface GetAllFacesOptions { - personId?: string | null; + personGroupId?: string | null; assetId?: string; sourceType?: SourceType; } export type UnassignFacesOptions = DeleteFacesOptions; -export type SelectFaceOptions = (keyof Selectable)[]; +export type GetFacesOptions = WithPersonOptions & { isVisible?: boolean }; -const withPerson = (eb: ExpressionBuilder) => { - return jsonObjectFrom( - eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'), - ).as('person'); +/** a person is identified by its owner and the group it belongs to */ +export type PersonId = { ownerId: string; personGroupId: string }; + +export type ReassignCluster = { userId: string; newClusterId: string }; + +export type WithPersonOptions = { + /** whose version of the person to select */ + viewingUserId: string; +}; + +const withPerson = ({ viewingUserId }: WithPersonOptions) => { + return (eb: ExpressionBuilder) => + jsonObjectFrom( + eb + .selectFrom('person') + .selectAll('person') + .whereRef('person.personGroupId', '=', 'asset_face.personGroupId') + .where('person.ownerId', '=', viewingUserId), + ).as('person'); }; const withFaceSearch = (eb: ExpressionBuilder) => { @@ -78,13 +95,18 @@ const withFaceSearch = (eb: ExpressionBuilder) => { export class PersonRepository { constructor(@InjectKysely() private db: Kysely) {} - @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) - async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise { + @GenerateSql({ params: [{ oldPersonGroupId: DummyValue.UUID, newPersonGroupId: DummyValue.UUID }] }) + async reassignFaces({ oldPersonGroupId, faceIds, ownerId, newPersonGroupId }: UpdateFacesData): Promise { const result = await this.db .updateTable('asset_face') - .set({ personId: newPersonId }) - .$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!)) + .set({ personGroupId: newPersonGroupId }) + .$if(!!oldPersonGroupId, (qb) => qb.where('asset_face.personGroupId', '=', oldPersonGroupId!)) .$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!)) + .$if(!!ownerId, (qb) => + qb.where('asset_face.personGroupId', 'in', (eb) => + eb.selectFrom('person').select('person.personGroupId').where('person.ownerId', '=', ownerId!), + ), + ) .executeTakeFirst(); return Number(result.numChangedRows ?? 0); @@ -93,19 +115,64 @@ export class PersonRepository { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { await this.db .updateTable('asset_face') - .set({ personId: null }) + .set({ personGroupId: null }) .where('asset_face.sourceType', '=', sourceType) .execute(); } + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.UUID] }) + @Chunked() + async delete(personGroupIds: string[], ownerId?: string) { + if (personGroupIds.length === 0) { + return []; + } + + return this.db + .deleteFrom('person') + .$if(!!ownerId, (qb) => qb.where('ownerId', '=', ownerId!)) + .where('person.personGroupId', 'in', personGroupIds) + .returning(['personGroupId', 'ownerId', 'thumbnailPath']) + .execute(); + } + @GenerateSql({ params: [[DummyValue.UUID]] }) @Chunked() - async delete(ids: string[]): Promise { + async deleteGroups(ids: string[]): Promise { if (ids.length === 0) { return; } - await this.db.deleteFrom('person').where('person.id', 'in', ids).execute(); + await this.db.deleteFrom('person_group').where('person_group.id', 'in', ids).execute(); + } + + @GenerateSql() + async deleteEmptyGroups(): Promise { + const result = await this.db + .deleteFrom('person_group') + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('person') + .whereRef('person.personGroupId', '=', 'person_group.id') + .select('person.personGroupId'), + ), + ), + ) + .executeTakeFirst(); + + return Number(result.numDeletedRows); + } + + @GenerateSql() + async deleteOrphanedClusterGroups(): Promise { + const result = await this.db + .deleteFrom('cluster_group') + .where(({ not, exists, selectFrom }) => + not(exists(selectFrom('user').whereRef('user.clusterGroupId', '=', 'cluster_group.id').select('user.id'))), + ) + .executeTakeFirst(); + + return Number(result.numDeletedRows); } async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { @@ -116,8 +183,8 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null)) - .$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!)) + .$if(options.personGroupId === null, (qb) => qb.where('asset_face.personGroupId', 'is', null)) + .$if(!!options.personGroupId, (qb) => qb.where('asset_face.personGroupId', '=', options.personGroupId!)) .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .where('asset_face.deletedAt', 'is', null) @@ -141,7 +208,7 @@ export class PersonRepository { getFileSamples() { return this.db .selectFrom('person') - .select(['id', 'thumbnailPath']) + .select(['ownerId', 'personGroupId', 'thumbnailPath']) .where('thumbnailPath', '!=', sql.lit('')) .limit(sql.lit(3)) .execute(); @@ -152,10 +219,11 @@ export class PersonRepository { const items = await this.db .selectFrom('person') .selectAll('person') - .innerJoin('asset_face', 'asset_face.personId', 'person.id') + .innerJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId') .innerJoin('asset', (join) => join .onRef('asset_face.assetId', '=', 'asset.id') + .onRef('asset.ownerId', '=', 'person.ownerId') .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.deletedAt', 'is', null), ) @@ -180,7 +248,7 @@ export class PersonRepository { ), ]), ) - .groupBy('person.id') + .groupBy(['person.ownerId', 'person.personGroupId']) .$if(!!options?.closestFaceAssetId, (qb) => qb.orderBy((eb) => eb( @@ -218,22 +286,22 @@ export class PersonRepository { return this.db .selectFrom('person') .selectAll('person') - .leftJoin('asset_face', 'asset_face.personId', 'person.id') + .leftJoin('asset_face', 'asset_face.personGroupId', 'person.personGroupId') .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) - .groupBy('person.id') + .groupBy(['person.ownerId', 'person.personGroupId']) .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getFaces(assetId: string, options?: { isVisible?: boolean }) { - const isVisible = options === undefined ? true : options.isVisible; + @GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID, isVisible: true }] }) + getFaces(assetId: string, options: GetFacesOptions) { + const { viewingUserId, isVisible } = options; return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withPerson) + .select(withPerson({ viewingUserId })) .where('asset_face.assetId', '=', assetId) .where('asset_face.deletedAt', 'is', null) .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) @@ -241,13 +309,13 @@ export class PersonRepository { .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getFaceById(id: string) { + @GenerateSql({ params: [DummyValue.UUID, { viewingUserId: DummyValue.UUID }] }) + getFaceById(id: string, { viewingUserId }: WithPersonOptions) { // TODO return null instead of find or fail return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withPerson) + .select(withPerson({ viewingUserId })) .where('asset_face.id', '=', id) .where('asset_face.deletedAt', 'is', null) .executeTakeFirstOrThrow(); @@ -257,12 +325,13 @@ export class PersonRepository { getFaceForFacialRecognitionJob(id: string) { return this.db .selectFrom('asset_face') - .select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType']) + .select(['asset_face.id', 'asset_face.personGroupId', 'asset_face.sourceType']) .select((eb) => jsonObjectFrom( eb .selectFrom('asset') - .select(['asset.ownerId', 'asset.visibility', 'asset.fileCreatedAt']) + .innerJoin('user', 'user.id', 'asset.ownerId') + .select(['asset.ownerId', 'asset.visibility', 'asset.fileCreatedAt', 'user.clusterGroupId']) .whereRef('asset.id', '=', 'asset_face.assetId'), ).as('asset'), ) @@ -272,8 +341,8 @@ export class PersonRepository { .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getDataForThumbnailGenerationJob(id: string) { + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] }) + getDataForThumbnailGenerationJob({ ownerId, personGroupId }: PersonId) { return this.db .selectFrom('person') .innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId') @@ -292,27 +361,30 @@ export class PersonRepository { 'asset_exif.orientation as exifOrientation', ]) .select((eb) => withFilePath(eb, AssetFileType.Preview).as('previewPath')) - .where('person.id', '=', id) + .where('person.ownerId', '=', ownerId) + .where('person.personGroupId', '=', personGroupId) .where('asset_face.deletedAt', 'is', null) .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) - async reassignFace(assetFaceId: string, newPersonId: string): Promise { + async reassignFace(assetFaceId: string, newPersonGroupId: string): Promise { const result = await this.db .updateTable('asset_face') - .set({ personId: newPersonId }) + .set({ personGroupId: newPersonGroupId }) .where('asset_face.id', '=', assetFaceId) .executeTakeFirst(); return Number(result.numChangedRows ?? 0); } - getById(personId: string) { + @GenerateSql({ params: [{ ownerId: DummyValue.UUID, personGroupId: DummyValue.UUID }] }) + getByGroupId({ ownerId, personGroupId }: PersonId) { return this.db // .selectFrom('person') .selectAll('person') - .where('person.id', '=', personId) + .where('person.personGroupId', '=', personGroupId) + .where('person.ownerId', '=', ownerId) .executeTakeFirst(); } @@ -336,27 +408,28 @@ export class PersonRepository { getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { return this.db .selectFrom('person') - .select(['person.id', 'person.name']) + .select(['person.personGroupId', 'person.name']) .distinctOn((eb) => eb.fn('lower', ['person.name'])) .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')])) .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - async getStatistics(personId: string): Promise { + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + async getStatistics(personGroupId: string, userId: string): Promise { const result = await this.db .selectFrom('asset_face') .leftJoin('asset', (join) => join .onRef('asset.id', '=', 'asset_face.assetId') .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) - .on('asset.deletedAt', 'is', null), + .on('asset.deletedAt', 'is', null) + .on((eb) => eb.or([eb('asset.ownerId', '=', asUuid(userId)), inSharedAlbum(eb, userId)])), ) .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) - .where('asset_face.personId', '=', personId) + .where('asset_face.personGroupId', '=', personGroupId) .executeTakeFirst(); return { @@ -373,7 +446,7 @@ export class PersonRepository { eb.exists((eb) => eb .selectFrom('asset_face') - .whereRef('asset_face.personId', '=', 'person.id') + .whereRef('asset_face.personGroupId', '=', 'person.personGroupId') .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', '=', true) .where((eb) => @@ -397,13 +470,124 @@ export class PersonRepository { return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); } - async createAll(people: Insertable[]): Promise { + async createAll(people: Insertable[]) { if (people.length === 0) { return []; } - const results = await this.db.insertInto('person').values(people).returningAll().execute(); - return results.map(({ id }) => id); + return this.db.insertInto('person').values(people).returningAll().execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + createGroup(ownerId: string) { + return this.db + .insertInto('person_group') + .columns(['clusterGroupId']) + .expression((eb) => eb.selectFrom('user').select('user.clusterGroupId').where('user.id', '=', ownerId)) + .returningAll() + .executeTakeFirstOrThrow(); + } + + @GenerateSql({ params: [{ userId: DummyValue.UUID, newClusterId: DummyValue.UUID }] }) + async reassignCluster({ userId, newClusterId }: ReassignCluster): Promise { + await this.db.transaction().execute(async (trx) => { + // a group nobody else has people in moves across as it is + await trx + .updateTable('person_group') + .set({ clusterGroupId: newClusterId }) + .where('person_group.id', 'in', (eb) => + eb.selectFrom('person').select('person.personGroupId').where('person.ownerId', '=', userId), + ) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('person') + .select('person.personGroupId') + .whereRef('person.personGroupId', '=', 'person_group.id') + .where('person.ownerId', '!=', userId), + ), + ), + ) + .execute(); + + // the rest is shared with someone else, so this user gets a group of their own for each + const mapping = await trx + .with('shared', (db) => + db + .selectFrom('person') + .select('person.personGroupId as oldId') + .distinct() + .where('person.ownerId', '=', userId) + .where(({ exists, selectFrom }) => + exists( + selectFrom('person as other') + .select('other.personGroupId') + .whereRef('other.personGroupId', '=', 'person.personGroupId') + .where('other.ownerId', '!=', userId), + ), + ), + ) + .with( + (cte) => cte('mapping').materialized(), + (db) => db.selectFrom('shared').select(['shared.oldId', sql`uuid_generate_v4()`.as('newId')]), + ) + .with('created', (db) => + db + .insertInto('person_group') + .columns(['id', 'clusterGroupId']) + .expression((eb) => + eb.selectFrom('mapping').select(['mapping.newId', sql.val(newClusterId).as('clusterGroupId')]), + ), + ) + .selectFrom('mapping') + .select(['mapping.oldId', 'mapping.newId']) + .execute(); + + if (mapping.length === 0) { + return; + } + + const oldIds = mapping.map(({ oldId }) => oldId); + const newIds = mapping.map(({ newId }) => newId); + const remapped = sql<{ + oldId: string; + newId: string; + }>`(select unnest(${`{${oldIds}}`}::uuid[]) as "oldId", unnest(${`{${newIds}}`}::uuid[]) as "newId")`.as( + 'mapping', + ); + + await trx + .updateTable('person') + .from(remapped) + .set((eb) => ({ personGroupId: eb.ref('mapping.newId') })) + .whereRef('person.personGroupId', '=', 'mapping.oldId') + .where('person.ownerId', '=', userId) + .execute(); + + await trx + .updateTable('asset_face') + .from(remapped) + .set((eb) => ({ personGroupId: eb.ref('mapping.newId') })) + .whereRef('asset_face.personGroupId', '=', 'mapping.oldId') + .where(({ exists, selectFrom }) => + exists( + selectFrom('asset') + .select('asset.id') + .whereRef('asset.id', '=', 'asset_face.assetId') + .where('asset.ownerId', '=', userId), + ), + ) + .execute(); + }); + } + + @GenerateSql({ params: [DummyValue.UUID, 2] }) + async createGroups(personGroups: Insertable[]) { + if (personGroups.length === 0) { + return []; + } + + return this.db.insertInto('person_group').values(personGroups).returningAll().execute(); } @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] }) @@ -430,11 +614,12 @@ export class PersonRepository { await query.selectFrom(dummy).execute(); } - async update(person: Updateable & { id: string }) { + async update(person: Updateable & PersonId) { return this.db .updateTable('person') .set(person) - .where('person.id', '=', person.id) + .where('person.ownerId', '=', person.ownerId) + .where('person.personGroupId', '=', person.personGroupId) .returningAll() .executeTakeFirstOrThrow(); } @@ -448,7 +633,7 @@ export class PersonRepository { .insertInto('person') .values(people) .onConflict((oc) => - oc.column('id').doUpdateSet((eb) => + oc.columns(['ownerId', 'personGroupId']).doUpdateSet((eb) => removeUndefinedKeys( { name: eb.ref('excluded.name'), @@ -466,36 +651,38 @@ export class PersonRepository { .execute(); } - @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) + @GenerateSql({ + params: [[{ assetId: DummyValue.UUID, personGroupId: DummyValue.UUID }], { viewingUserId: DummyValue.UUID }], + }) @ChunkedArray() - getFacesByIds(ids: AssetFaceId[]) { + getFacesByIds(ids: AssetFaceId[], { viewingUserId }: WithPersonOptions) { if (ids.length === 0) { return Promise.resolve([]); } const assetIds: string[] = []; - const personIds: string[] = []; - for (const { assetId, personId } of ids) { + const personGroupIds: string[] = []; + for (const { assetId, personGroupId } of ids) { assetIds.push(assetId); - personIds.push(personId); + personGroupIds.push(personGroupId); } return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withPerson) + .select(withPerson({ viewingUserId })) .where('asset_face.assetId', 'in', assetIds) - .where('asset_face.personId', 'in', personIds) + .where('asset_face.personGroupId', 'in', personGroupIds) .where('asset_face.deletedAt', 'is', null) .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getRandomFace(personId: string) { + getRandomFace(personGroupId: string) { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .where('asset_face.personId', '=', personId) + .where('asset_face.personGroupId', '=', personGroupId) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) .executeTakeFirst(); @@ -534,15 +721,6 @@ export class PersonRepository { } } - @GenerateSql({ params: [[DummyValue.UUID]] }) - @Chunked() - getForPeopleDelete(ids: string[]) { - if (ids.length === 0) { - return Promise.resolve([]); - } - return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute(); - } - @GenerateSql({ params: [[], []] }) async updateVisibility(visible: AssetFace[], hidden: AssetFace[]): Promise { if (visible.length === 0 && hidden.length === 0) { @@ -576,14 +754,24 @@ export class PersonRepository { }); } - @GenerateSql({ params: [{ personId: DummyValue.UUID, assetId: DummyValue.UUID }] }) - getForFeatureFaceUpdate({ personId, assetId }: { personId: string; assetId: string }) { + @GenerateSql({ params: [{ personGroupId: DummyValue.UUID, assetId: DummyValue.UUID }] }) + getForFeatureFaceUpdate({ personGroupId, assetId }: { personGroupId: string; assetId: string }) { return this.db .selectFrom('asset_face') .select('asset_face.id') .where('asset_face.assetId', '=', assetId) - .where('asset_face.personId', '=', personId) + .where('asset_face.personGroupId', '=', personGroupId) .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) .executeTakeFirst(); } + + @GenerateSql({ params: [[DummyValue.UUID]] }) + getForMergePerson(personGroupIds: string[]) { + return this.db + .selectFrom('person') + .selectAll('person') + .where('person.personGroupId', 'in', personGroupIds) + .orderBy('person.ownerId') + .execute(); + } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 70ba09ef2c..ce055a0044 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -54,6 +54,8 @@ export interface SearchOneToOneRelationOptions { export interface SearchRelationOptions extends SearchOneToOneRelationOptions { withFaces?: boolean; withPeople?: boolean; + /** whose version of the people to select, required when selecting faces or people */ + viewingUserId?: string; } export interface SearchDateOptions { @@ -137,6 +139,8 @@ export interface AssetSearchBuilderV3Options { filter?: SearchFilter; /** Server-derived ownership scope. Never client-controlled. */ userIds?: string[]; + /** whose version of the people to select, required when selecting faces or people */ + viewingUserId?: string; withExif?: boolean; withFaces?: boolean; withPeople?: boolean; @@ -156,13 +160,12 @@ export type SmartSearchOptions = SearchDateOptions & SearchUserIdOptions & SearchPeopleOptions & SearchTagOptions & - SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked' }; - -export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions; + SearchOcrOptions & { visibility?: AssetVisibility | 'not-locked'; viewingUserId?: string }; export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number }; -export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { +export interface FaceEmbeddingSearch extends Omit { + clusterGroupId: string; hasPerson?: boolean; numResults: number; maxDistance: number; @@ -172,7 +175,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { export interface FaceSearchResult { distance: number; id: string; - personId: string | null; + personGroupId: string | null; } export interface AssetDuplicateResult { @@ -341,7 +344,7 @@ export class SearchRepository { }, ], }) - searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) { + searchFaces({ clusterGroupId, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) { if (!z.int().min(1).max(1000).safeParse(numResults).success) { throw new Error(`Invalid value for 'numResults': ${numResults}`); } @@ -352,20 +355,29 @@ export class SearchRepository { .with('cte', (qb) => qb .selectFrom('asset_face') - .select([ - 'asset_face.id', - 'asset_face.personId', - sql`face_search.embedding <=> ${embedding}`.as('distance'), - ]) .innerJoin('asset', 'asset.id', 'asset_face.assetId') .innerJoin('face_search', 'face_search.faceId', 'asset_face.id') - .leftJoin('person', 'person.id', 'asset_face.personId') - .where('asset.ownerId', '=', anyUuid(userIds)) + .select([ + 'asset_face.id', + 'asset_face.personGroupId', + sql`face_search.embedding <=> ${embedding}`.as('distance'), + ]) + .where('asset.ownerId', 'in', (eb) => + eb.selectFrom('user').select('user.id').where('user.clusterGroupId', '=', clusterGroupId), + ) .where('asset.deletedAt', 'is', null) - .$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null)) + .$if(!!hasPerson, (qb) => qb.where('asset_face.personGroupId', 'is not', null)) .$if(!!minBirthDate, (qb) => qb.where((eb) => - eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), + eb.not( + eb.exists( + eb + .selectFrom('person') + .select('person.personGroupId') + .whereRef('person.personGroupId', '=', 'asset_face.personGroupId') + .where('person.birthDate', '>', minBirthDate!), + ), + ), ), ) .orderBy('distance') diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index 285ab4ee80..4b0e13356d 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -4,20 +4,10 @@ import { exec as execCallback } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { promisify } from 'node:util'; import sharp from 'sharp'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; +import { ReleaseChannel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; -export interface GitHubRelease { - id: number; - url: string; - tag_name: string; - name: string; - created_at: string; - published_at: string; - body: string; -} - export interface VersionResponse { version: string; published_at: string; diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index f1cb541b39..58a5e721c0 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -9,8 +9,6 @@ import { DB } from 'src/schema'; import { SessionTable } from 'src/schema/tables/session.table'; import { asUuid } from 'src/utils/database'; -export type SessionSearchOptions = { updatedBefore: Date }; - @Injectable() export class SessionRepository { constructor(@InjectKysely() private db: Kysely) {} diff --git a/server/src/repositories/stack.repository.ts b/server/src/repositories/stack.repository.ts index d313d682bd..1cb0b33fce 100644 --- a/server/src/repositories/stack.repository.ts +++ b/server/src/repositories/stack.repository.ts @@ -41,7 +41,8 @@ const withAssets = (eb: ExpressionBuilder, withTags = false) => { .select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')) .where('asset.deletedAt', 'is', null) .whereRef('asset.stackId', '=', 'stack.id') - .$call(withDefaultVisibility), + .$call(withDefaultVisibility) + .orderBy('asset.fileCreatedAt', 'asc'), ).as('assets'); }; diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 9604372fbe..7ee74d9870 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -31,6 +31,7 @@ export interface WatchEvents { export interface ImmichReadStream { stream: Readable; type?: string; + disposition?: string | string[]; length?: number; } diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index eca8d18e66..a8359cac5a 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -65,6 +65,7 @@ export class SyncRepository { partnerAssetExif: PartnerAssetExifsSync; partnerStack: PartnerStackSync; person: PersonSync; + personGroup: PersonGroupSync; stack: StackSync; user: UserSync; userMetadata: UserMetadataSync; @@ -89,6 +90,7 @@ export class SyncRepository { this.partnerAssetExif = new PartnerAssetExifsSync(this.db); this.partnerStack = new PartnerStackSync(this.db); this.person = new PersonSync(this.db); + this.personGroup = new PersonGroupSync(this.db); this.stack = new StackSync(this.db); this.user = new UserSync(this.db); this.userMetadata = new UserMetadataSync(this.db); @@ -422,7 +424,7 @@ class PersonSync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getDeletes(options: SyncQueryOptions) { return this.auditQuery('person_audit', options) - .select(['id', 'personId']) + .select(['id', 'personGroupId as personId']) .where('ownerId', '=', options.userId) .stream(); } @@ -435,7 +437,7 @@ class PersonSync extends BaseSync { getUpserts(options: SyncQueryOptions) { return this.upsertQuery('person', options) .select([ - 'id', + 'personGroupId as id', 'createdAt', 'updatedAt', 'ownerId', @@ -452,6 +454,12 @@ class PersonSync extends BaseSync { } } +class PersonGroupSync extends BaseSync { + cleanupAuditTable(daysAgo: number) { + return this.auditCleanup('person_group_audit', daysAgo); + } +} + class AssetFaceSync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getDeletes(options: SyncQueryOptions) { @@ -472,7 +480,7 @@ class AssetFaceSync extends BaseSync { .select([ 'asset_face.id', 'assetId', - 'personId', + 'personGroupId as personId', 'imageWidth', 'imageHeight', 'boundingBoxX1', diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 9c946fe583..8344896199 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -4,10 +4,12 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; +import { WorkflowGetLogsDto, WorkflowSearchDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; +import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; +import { withTags } from 'src/utils/database'; export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @@ -26,6 +28,7 @@ export class WorkflowRepository { 'workflow.enabled', 'workflow.createdAt', 'workflow.updatedAt', + 'workflow.logging', ]) .select((eb) => [ jsonArrayFrom( @@ -65,7 +68,7 @@ export class WorkflowRepository { getForWorkflowRun(id: string) { return this.db .selectFrom('workflow') - .select(['workflow.id', 'workflow.name', 'workflow.trigger']) + .select(['workflow.id', 'workflow.name', 'workflow.trigger', 'workflow.logging']) .select((eb) => [ jsonArrayFrom( eb @@ -98,6 +101,9 @@ export class WorkflowRepository { update(id: string, dto: Updateable, steps?: WorkflowStepUpsert[]) { return this.db.transaction().execute(async (tx) => { + if (dto.logging === false) { + await tx.deleteFrom('workflow_log').where('workflowId', '=', id).execute(); + } if (Object.values(dto).some((prop) => prop !== undefined)) { await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow(); } @@ -105,6 +111,39 @@ export class WorkflowRepository { }); } + @GenerateSql({ params: [DummyValue.UUID, { result: undefined }] }) + getLogs(id: string, dto: WorkflowGetLogsDto) { + return this.db + .selectFrom('workflow_log') + .select([ + 'workflow_log.id', + 'workflow_log.createdAt', + 'workflow_log.result', + 'workflow_log.workflowId', + 'workflow_log.workflowStepId', + 'workflow_log.triggerDataId', + ]) + .where('workflow_log.workflowId', '=', id) + .select((eb) => [ + jsonObjectFrom( + eb + .selectFrom('workflow_step') + .whereRef('workflow_step.id', '=', 'workflow_log.workflowStepId') + .innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId') + .select(['plugin_method.pluginId', 'plugin_method.name as methodName', 'workflow_step.order']), + ).as('step'), + ]) + .$if(dto.result !== undefined, (qb) => qb.where('workflow_log.result', '=', dto.result!)) + .$if(dto.before !== undefined, (qb) => qb.where('workflow_log.createdAt', '<', dto.before!)) + .orderBy('workflow_log.createdAt', 'desc') + .limit(dto.limit) + .execute(); + } + + log(dto: Insertable) { + return this.db.insertInto('workflow_log').values(dto).execute(); + } + async updateStep(id: string, dto: Updateable) { await this.db.updateTable('workflow_step').where('workflow_step.id', '=', id).set(dto).execute(); } @@ -143,6 +182,7 @@ export class WorkflowRepository { .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select((eb) => [ ...columns.workflowAssetV1, + withTags, jsonObjectFrom( eb .selectFrom('asset_exif') diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 2ee61d82dd..531c9b81bf 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -212,8 +212,21 @@ export const person_delete_audit = registerFunction({ language: 'PLPGSQL', body: ` BEGIN - INSERT INTO person_audit ("personId", "ownerId") - SELECT "id", "ownerId" + INSERT INTO person_audit ("personGroupId", "ownerId") + SELECT "personGroupId", "ownerId" + FROM OLD; + RETURN NULL; + END`, +}); + +export const person_group_delete_audit = registerFunction({ + name: 'person_group_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO person_group_audit ("personGroupId", "clusterGroupId") + SELECT "id", "clusterGroupId" FROM OLD; RETURN NULL; END`, diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 171fefb50f..61dc5d8760 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -21,6 +21,7 @@ import { memory_delete_audit, partner_delete_audit, person_delete_audit, + person_group_delete_audit, stack_delete_audit, updated_at, user_delete_audit, @@ -48,6 +49,8 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table'; +import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table'; @@ -63,6 +66,8 @@ import { OcrSearchTable } from 'src/schema/tables/ocr-search.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; +import { PersonGroupAuditTable } from 'src/schema/tables/person-group-audit.table'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; import { PluginTable } from 'src/schema/tables/plugin.table'; @@ -87,6 +92,7 @@ import { VideoStreamSessionTable, VideoStreamVariantTable, } from 'src/schema/tables/video-stream.table'; +import { WorkflowLogTable } from 'src/schema/tables/workflow-log.table'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; @@ -115,6 +121,8 @@ export class ImmichDatabase { AssetTable, AssetFileTable, AssetExifTable, + ClusterGroupTable, + ClusterGroupRequestTable, FaceSearchTable, GeodataPlacesTable, IntegrityReportTable, @@ -131,6 +139,8 @@ export class ImmichDatabase { PartnerTable, PersonTable, PersonAuditTable, + PersonGroupTable, + PersonGroupAuditTable, SessionTable, SharedLinkAssetTable, SharedLinkTable, @@ -171,6 +181,7 @@ export class ImmichDatabase { memory_asset_delete_audit, stack_delete_audit, person_delete_audit, + person_group_delete_audit, user_metadata_audit, asset_metadata_audit, asset_face_audit, @@ -245,6 +256,11 @@ export interface DB { person: PersonTable; person_audit: PersonAuditTable; + person_group: PersonGroupTable; + person_group_audit: PersonGroupAuditTable; + + cluster_group: ClusterGroupTable; + cluster_group_request: ClusterGroupRequestTable; session: SessionTable; session_sync_checkpoint: SessionSyncCheckpointTable; @@ -279,4 +295,5 @@ export interface DB { workflow: WorkflowTable; workflow_step: WorkflowStepTable; + workflow_log: WorkflowLogTable; } diff --git a/server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts b/server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts new file mode 100644 index 0000000000..13fa5f5f97 --- /dev/null +++ b/server/src/schema/migrations/1786741078327-AddWorkflowLogsTable.ts @@ -0,0 +1,24 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" ADD "logging" boolean NOT NULL DEFAULT false;`.execute(db); + await sql`CREATE TABLE "workflow_log" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "workflowId" uuid NOT NULL, + "result" character varying NOT NULL, + "workflowStepId" uuid, + "triggerDataId" uuid, + "runId" uuid NOT NULL, + CONSTRAINT "workflow_log_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_log_workflowStepId_fkey" FOREIGN KEY ("workflowStepId") REFERENCES "workflow_step" ("id") ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT "workflow_log_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_log_workflowId_idx" ON "workflow_log" ("workflowId");`.execute(db); + await sql`CREATE INDEX "workflow_log_workflowStepId_idx" ON "workflow_log" ("workflowStepId");`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" DROP COLUMN "logging";`.execute(db); + await sql`DROP TABLE "workflow_log";`.execute(db); +} diff --git a/server/src/schema/migrations/1786972746371-AssetOcrUpdatedAtTrigger.ts b/server/src/schema/migrations/1786972746371-AssetOcrUpdatedAtTrigger.ts new file mode 100644 index 0000000000..3ad19ead6f --- /dev/null +++ b/server/src/schema/migrations/1786972746371-AssetOcrUpdatedAtTrigger.ts @@ -0,0 +1,16 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_ocr" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_ocr_updatedAt" + BEFORE UPDATE ON "asset_ocr" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_ocr_updatedAt', '{"type":"trigger","name":"asset_ocr_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_ocr_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_ocr\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_ocr_updatedAt" ON "asset_ocr";`.execute(db); + await sql`ALTER TABLE "asset_ocr" DROP COLUMN "updatedAt";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_ocr_updatedAt';`.execute(db); +} diff --git a/server/src/schema/migrations/1786972746372-AssetOcrSyncReset.ts b/server/src/schema/migrations/1786972746372-AssetOcrSyncReset.ts new file mode 100644 index 0000000000..767778e6a1 --- /dev/null +++ b/server/src/schema/migrations/1786972746372-AssetOcrSyncReset.ts @@ -0,0 +1,12 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // OCR visibility updates did not bump updateId before the asset_ocr_updatedAt trigger was added, + // so clients never received those changes. There is no way to know which rows were missed, so + // reset the checkpoint to backfill all OCR rows on the next sync. + await sql`DELETE FROM session_sync_checkpoint WHERE type = 'AssetOcrV1'`.execute(db); +} + +export async function down(): Promise { + // Not implemented +} diff --git a/server/src/schema/migrations/1787148183729-ClusterGroups.ts b/server/src/schema/migrations/1787148183729-ClusterGroups.ts new file mode 100644 index 0000000000..13b95762d8 --- /dev/null +++ b/server/src/schema/migrations/1787148183729-ClusterGroups.ts @@ -0,0 +1,232 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION person_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person_audit ("personGroupId", "ownerId") + SELECT "personGroupId", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_audit" + AFTER DELETE ON "person" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() <= 1) + EXECUTE FUNCTION person_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE FUNCTION person_group_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person_group_audit ("personGroupId", "clusterGroupId") + SELECT "id", "clusterGroupId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "cluster_group" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name" character varying, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + CONSTRAINT "cluster_group_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "cluster_group_updateId_idx" ON "cluster_group" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "cluster_group_updatedAt" + BEFORE UPDATE ON "cluster_group" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + + await sql`ALTER TABLE "user" ADD "clusterGroupId" uuid;`.execute(db); + await sql`UPDATE "user" SET "clusterGroupId" = uuid_generate_v4();`.execute(db); + await sql`INSERT INTO "cluster_group" ("id") SELECT "clusterGroupId" FROM "user";`.execute(db); + await sql`ALTER TABLE "user" ALTER COLUMN "clusterGroupId" SET NOT NULL;`.execute(db); + await sql`CREATE INDEX "user_clusterGroupId_idx" ON "user" ("clusterGroupId");`.execute(db); + await sql`ALTER TABLE "user" ADD CONSTRAINT "user_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE NO ACTION;`.execute(db); + + await sql`CREATE TABLE "cluster_group_request" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "clusterGroupId" uuid NOT NULL, + "userId" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "cluster_group_request_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "cluster_group_request_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "cluster_group_request_clusterGroupId_userId_uq" UNIQUE ("clusterGroupId", "userId"), + CONSTRAINT "cluster_group_request_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "cluster_group_request_clusterGroupId_idx" ON "cluster_group_request" ("clusterGroupId");`.execute( + db, + ); + await sql`CREATE INDEX "cluster_group_request_userId_idx" ON "cluster_group_request" ("userId");`.execute(db); + + await sql`CREATE TABLE "person_group" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "clusterGroupId" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "createId" uuid NOT NULL DEFAULT immich_uuid_v7(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + CONSTRAINT "person_group_clusterGroupId_fkey" FOREIGN KEY ("clusterGroupId") REFERENCES "cluster_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "person_group_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "person_group_clusterGroupId_idx" ON "person_group" ("clusterGroupId");`.execute(db); + await sql`CREATE INDEX "person_group_createId_idx" ON "person_group" ("createId");`.execute(db); + await sql`CREATE INDEX "person_group_updateId_idx" ON "person_group" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_group_delete_audit" + AFTER DELETE ON "person_group" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION person_group_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_group_updatedAt" + BEFORE UPDATE ON "person_group" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`CREATE TABLE "person_group_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "personGroupId" uuid NOT NULL, + "clusterGroupId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "person_group_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "person_group_audit_personGroupId_idx" ON "person_group_audit" ("personGroupId");`.execute(db); + await sql`CREATE INDEX "person_group_audit_clusterGroupId_idx" ON "person_group_audit" ("clusterGroupId");`.execute(db); + await sql`CREATE INDEX "person_group_audit_deletedAt_idx" ON "person_group_audit" ("deletedAt");`.execute(db); + + await sql`ALTER TABLE "person" ADD "personGroupId" uuid;`.execute(db); + await sql`INSERT INTO "person_group" ("id", "clusterGroupId", "createdAt") + SELECT "person"."id", "user"."clusterGroupId", "person"."createdAt" + FROM "person" + INNER JOIN "user" ON "user"."id" = "person"."ownerId";`.execute(db); + await sql`UPDATE "person" SET "personGroupId" = "id";`.execute(db); + await sql`ALTER TABLE "person" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db); + await sql`CREATE INDEX "person_personGroupId_idx" ON "person" ("personGroupId");`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "person_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + + await sql`ALTER TABLE "person_audit" ADD "personGroupId" uuid;`.execute(db); + await sql`UPDATE "person_audit" SET "personGroupId" = "personId";`.execute(db); + await sql`ALTER TABLE "person_audit" ALTER COLUMN "personGroupId" SET NOT NULL;`.execute(db); + await sql`ALTER TABLE "person_audit" DROP COLUMN "personId";`.execute(db); + await sql`CREATE INDEX "person_audit_personGroupId_idx" ON "person_audit" ("personGroupId");`.execute(db); + + await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db); + await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "personGroupId";`.execute(db); + await sql`UPDATE "asset_face" SET "personGroupId" = "person"."personGroupId" + FROM "person" + WHERE "person"."id" = "asset_face"."personGroupId";`.execute(db); + await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personGroupId_fkey" FOREIGN KEY ("personGroupId") REFERENCES "person_group" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + db, + ); + await sql`CREATE INDEX "asset_face_personGroupId_assetId_idx" ON "asset_face" ("personGroupId", "assetId");`.execute( + db, + ); + await sql`CREATE INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personGroupId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute( + db, + ); + await sql`CREATE INDEX "asset_face_assetId_personGroupId_idx" ON "asset_face" ("assetId", "personGroupId");`.execute( + db, + ); + await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db); + + // a person is identified by its owner and the group it belongs to + await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db); + await sql`ALTER TABLE "person" DROP COLUMN "id";`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("ownerId", "personGroupId");`.execute(db); + await sql`DROP INDEX "person_ownerId_idx";`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personGroupId\\", \\"ownerId\\")\\n SELECT \\"personGroupId\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() <= 1)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_group_delete_audit', '{"type":"function","name":"person_group_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_group_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_group_audit (\\"personGroupId\\", \\"clusterGroupId\\")\\n SELECT \\"id\\", \\"clusterGroupId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_cluster_group_updatedAt', '{"type":"trigger","name":"cluster_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"cluster_group_updatedAt\\"\\n BEFORE UPDATE ON \\"cluster_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_delete_audit', '{"type":"trigger","name":"person_group_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_group_delete_audit\\"\\n AFTER DELETE ON \\"person_group\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_group_delete_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_group_updatedAt', '{"type":"trigger","name":"person_group_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_group_updatedAt\\"\\n BEFORE UPDATE ON \\"person_group\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personGroupId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personGroupId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personGroupId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION person_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO person_audit ("personId", "ownerId") + SELECT "id", "ownerId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_audit" + AFTER DELETE ON "person" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION person_delete_audit();`.execute(db); + + await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db); + await sql`ALTER TABLE "person" ADD "id" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db); + await sql`UPDATE "person" SET "id" = "personGroupId";`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("id");`.execute(db); + await sql`CREATE INDEX "person_ownerId_idx" ON "person" ("ownerId");`.execute(db); + + await sql`DROP INDEX "asset_face_assetId_personGroupId_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personGroupId_assetId_notDeleted_isVisible_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personGroupId_assetId_idx";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personGroupId_fkey";`.execute(db); + await sql`ALTER TABLE "asset_face" RENAME COLUMN "personGroupId" TO "personId";`.execute(db); + await sql`UPDATE "asset_face" SET "personId" = "person"."id" + FROM "person" + WHERE "person"."personGroupId" = "asset_face"."personId";`.execute(db); + await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + db, + ); + await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db); + await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db); + await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute( + db, + ); + + await sql`ALTER TABLE "person_audit" ADD "personId" uuid;`.execute(db); + await sql`UPDATE "person_audit" SET "personId" = "personGroupId";`.execute(db); + await sql`ALTER TABLE "person_audit" ALTER COLUMN "personId" SET NOT NULL;`.execute(db); + await sql`CREATE INDEX "person_audit_personId_idx" ON "person_audit" ("personId");`.execute(db); + await sql`DROP INDEX "person_audit_personGroupId_idx";`.execute(db); + await sql`ALTER TABLE "person_audit" DROP COLUMN "personGroupId";`.execute(db); + await sql`ALTER TABLE "person" DROP CONSTRAINT "person_personGroupId_fkey";`.execute(db); + await sql`DROP INDEX "person_personGroupId_idx";`.execute(db); + await sql`ALTER TABLE "person" DROP COLUMN "personGroupId";`.execute(db); + await sql`ALTER TABLE "user" DROP CONSTRAINT "user_clusterGroupId_fkey";`.execute(db); + await sql`DROP INDEX "user_clusterGroupId_idx";`.execute(db); + await sql`ALTER TABLE "user" DROP COLUMN "clusterGroupId";`.execute(db); + await sql`DROP TABLE "cluster_group_request";`.execute(db); + await sql`DROP TABLE "person_group_audit";`.execute(db); + await sql`DROP TABLE "person_group";`.execute(db); + await sql`DROP TABLE "cluster_group";`.execute(db); + await sql`DROP FUNCTION person_group_delete_audit;`.execute(db); + + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_person_delete_audit';`.execute( + db, + ); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb WHERE "name" = 'trigger_person_delete_audit';`.execute( + db, + ); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_group_delete_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_cluster_group_updatedAt';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_delete_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_group_updatedAt';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personGroupId_assetId_notDeleted_isVisible_idx';`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute( + db, + ); +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index b67e5e5dac..9832f99ce8 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -15,7 +15,7 @@ import { SourceType } from 'src/enum'; import { asset_face_source_type } from 'src/schema/enums'; import { asset_face_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { PersonTable } from 'src/schema/tables/person.table'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; @Table({ name: 'asset_face' }) @UpdatedAtTrigger('asset_face_updatedAt') @@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table'; when: 'pg_trigger_depth() = 0', }) // schemaFromDatabase does not preserve column order -@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) +@Index({ name: 'asset_face_assetId_personGroupId_idx', columns: ['assetId', 'personGroupId'] }) @Index({ - name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', - columns: ['personId', 'assetId'], + name: 'asset_face_personGroupId_assetId_notDeleted_isVisible_idx', + columns: ['personGroupId', 'assetId'], where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', }) -@Index({ columns: ['personId', 'assetId'] }) +@Index({ columns: ['personGroupId', 'assetId'] }) export class AssetFaceTable { @PrimaryGeneratedColumn() id!: Generated; @@ -40,19 +40,19 @@ export class AssetFaceTable { @ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', - // [assetId, personId] is the PK constraint + // [assetId, personGroupId] is the PK constraint index: false, }) assetId!: string; - @ForeignKeyColumn(() => PersonTable, { + @ForeignKeyColumn(() => PersonGroupTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true, - // [personId, assetId] makes this redundant + // [personGroupId, assetId] makes this redundant index: false, }) - personId!: string | null; + personGroupId!: string | null; @Column({ default: 0, type: 'integer' }) imageWidth!: Generated; diff --git a/server/src/schema/tables/asset-ocr.table.ts b/server/src/schema/tables/asset-ocr.table.ts index f2c4e40032..80f6c52dae 100644 --- a/server/src/schema/tables/asset-ocr.table.ts +++ b/server/src/schema/tables/asset-ocr.table.ts @@ -5,12 +5,15 @@ import { Generated, PrimaryGeneratedColumn, Table, + Timestamp, + UpdateDateColumn, } from '@immich/sql-tools'; -import { UpdateIdColumn } from 'src/decorators'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { asset_ocr_delete_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; @Table('asset_ocr') +@UpdatedAtTrigger('asset_ocr_updatedAt') @AfterDeleteTrigger({ scope: 'statement', function: asset_ocr_delete_audit, @@ -61,6 +64,9 @@ export class AssetOcrTable { @Column({ type: 'boolean', default: true }) isVisible!: Generated; + @UpdateDateColumn() + updatedAt!: Generated; + @UpdateIdColumn({ index: true }) updateId!: Generated; } diff --git a/server/src/schema/tables/cluster-group-request.table.ts b/server/src/schema/tables/cluster-group-request.table.ts new file mode 100644 index 0000000000..d85ab06184 --- /dev/null +++ b/server/src/schema/tables/cluster-group-request.table.ts @@ -0,0 +1,27 @@ +import { + CreateDateColumn, + ForeignKeyColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, + Unique, +} from '@immich/sql-tools'; +import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table'; +import { UserTable } from 'src/schema/tables/user.table'; + +@Table('cluster_group_request') +@Unique({ columns: ['clusterGroupId', 'userId'] }) +export class ClusterGroupRequestTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + clusterGroupId!: string; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + userId!: string; + + @CreateDateColumn() + createdAt!: Generated; +} diff --git a/server/src/schema/tables/cluster-group.table.ts b/server/src/schema/tables/cluster-group.table.ts new file mode 100644 index 0000000000..79c7d91db5 --- /dev/null +++ b/server/src/schema/tables/cluster-group.table.ts @@ -0,0 +1,29 @@ +import { + Column, + CreateDateColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; + +@Table('cluster_group') +@UpdatedAtTrigger('cluster_group_updatedAt') +export class ClusterGroupTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @Column({ type: 'character varying', nullable: true, default: null }) + name!: string | null; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/person-audit.table.ts b/server/src/schema/tables/person-audit.table.ts index 4fb55f1744..4045c2917b 100644 --- a/server/src/schema/tables/person-audit.table.ts +++ b/server/src/schema/tables/person-audit.table.ts @@ -7,7 +7,7 @@ export class PersonAuditTable { id!: Generated; @Column({ type: 'uuid', index: true }) - personId!: string; + personGroupId!: string; @Column({ type: 'uuid', index: true }) ownerId!: string; diff --git a/server/src/schema/tables/person-group-audit.table.ts b/server/src/schema/tables/person-group-audit.table.ts new file mode 100644 index 0000000000..a8a8117d50 --- /dev/null +++ b/server/src/schema/tables/person-group-audit.table.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; + +@Table('person_group_audit') +export class PersonGroupAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + personGroupId!: string; + + @Column({ type: 'uuid', index: true }) + clusterGroupId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/person-group.table.ts b/server/src/schema/tables/person-group.table.ts new file mode 100644 index 0000000000..36e4c4a949 --- /dev/null +++ b/server/src/schema/tables/person-group.table.ts @@ -0,0 +1,41 @@ +import { + AfterDeleteTrigger, + CreateDateColumn, + ForeignKeyColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { person_group_delete_audit } from 'src/schema/functions'; +import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table'; + +@Table('person_group') +@UpdatedAtTrigger('person_group_updatedAt') +@AfterDeleteTrigger({ + scope: 'statement', + function: person_group_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) +export class PersonGroupTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => ClusterGroupTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + clusterGroupId!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @CreateIdColumn({ index: true }) + createId!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 35447acfd0..0c9324c928 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -6,7 +6,6 @@ import { ForeignKeyColumn, Generated, Index, - PrimaryGeneratedColumn, Table, Timestamp, UpdateDateColumn, @@ -14,6 +13,7 @@ import { import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { person_delete_audit } from 'src/schema/functions'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { UserTable } from 'src/schema/tables/user.table'; @Table('person') @@ -27,12 +27,25 @@ import { UserTable } from 'src/schema/tables/user.table'; scope: 'statement', function: person_delete_audit, referencingOldTableAs: 'old', - when: 'pg_trigger_depth() = 0', + when: 'pg_trigger_depth() <= 1', }) @Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` }) export class PersonTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; + @ForeignKeyColumn(() => UserTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + primary: true, + // [ownerId, personGroupId] is the PK constraint + index: false, + }) + ownerId!: string; + + @ForeignKeyColumn(() => PersonGroupTable, { + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + primary: true, + }) + personGroupId!: string; @CreateDateColumn() createdAt!: Generated; @@ -40,9 +53,6 @@ export class PersonTable { @UpdateDateColumn() updatedAt!: Generated; - @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) - ownerId!: string; - @Column({ default: '' }) name!: Generated; diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 50d56d9067..be8b05d129 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -3,6 +3,7 @@ import { Column, CreateDateColumn, DeleteDateColumn, + ForeignKeyColumn, Generated, Index, PrimaryGeneratedColumn, @@ -14,6 +15,7 @@ import { ColumnType } from 'kysely'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UserAvatarColor, UserStatus } from 'src/enum'; import { user_delete_audit } from 'src/schema/functions'; +import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table'; @Table('user') @UpdatedAtTrigger('user_updatedAt') @@ -82,4 +84,7 @@ export class UserTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @ForeignKeyColumn(() => ClusterGroupTable, { onUpdate: 'CASCADE', nullable: false }) + clusterGroupId!: string; } diff --git a/server/src/schema/tables/workflow-log.table.ts b/server/src/schema/tables/workflow-log.table.ts new file mode 100644 index 0000000000..e720cb2a37 --- /dev/null +++ b/server/src/schema/tables/workflow-log.table.ts @@ -0,0 +1,36 @@ +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, +} from '@immich/sql-tools'; +import { WorkflowResult } from 'src/enum'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Table('workflow_log') +export class WorkflowLogTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', index: true }) + workflowId!: string; + + @Column() + result!: WorkflowResult; + + @ForeignKeyColumn(() => WorkflowStepTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true }) + workflowStepId!: string | null; + + @Column({ type: 'uuid', nullable: true }) + triggerDataId!: string | null; + + @Column({ type: 'uuid' }) + runId!: string; +} diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 944fffd9d5..c0c9dc94e4 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -41,4 +41,7 @@ export class WorkflowTable { @Column({ type: 'boolean', default: true }) enabled!: Generated; + + @Column({ type: 'boolean', default: false }) + logging!: Generated; } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 68165d642f..64ae09a0aa 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -188,6 +188,51 @@ describe(ApiKeyService.name, () => { }); }); + describe('rotate', () => { + it('should throw an error if the key is not found', async () => { + const auth = AuthFactory.create(); + const id = newUuid(); + + mocks.apiKey.getById.mockResolvedValue(void 0); + + await expect(sut.rotate(auth, id)).rejects.toBeInstanceOf(BadRequestException); + + expect(mocks.apiKey.update).not.toHaveBeenCalled(); + }); + + it('should replace the secret of a key', async () => { + const auth = AuthFactory.create(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id }); + + mocks.crypto.randomBytesAsText.mockReturnValue('super-secret'); + mocks.apiKey.getById.mockResolvedValue(apiKey); + mocks.apiKey.update.mockResolvedValue(apiKey); + + await expect(sut.rotate(auth, apiKey.id)).resolves.toEqual( + expect.objectContaining({ secret: 'super-secret', apiKey: expect.objectContaining({ id: apiKey.id }) }), + ); + + expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { + key: Buffer.from('super-secret (hashed)'), + }); + }); + + it('should not rotate a key with permissions the caller does not have', async () => { + const auth = AuthFactory.from() + .apiKey({ permissions: [Permission.ApiKeyRotate] }) + .build(); + const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] }); + + mocks.apiKey.getById.mockResolvedValue(apiKey); + + await expect(sut.rotate(auth, apiKey.id)).rejects.toThrow( + 'Cannot rotate an API Key with permissions you do not have', + ); + + expect(mocks.apiKey.update).not.toHaveBeenCalled(); + }); + }); + describe('delete', () => { it('should throw an error if the key is not found', async () => { const auth = AuthFactory.create(); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index acd3b17e94..d65ebc4d96 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -5,6 +5,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; +import { findOrFail } from 'src/utils/misc'; @Injectable() export class ApiKeyService extends BaseService { @@ -22,8 +23,9 @@ export class ApiKeyService extends BaseService { userId: auth.user.id, permissions: dto.permissions, }); + const apiKey = this.map(entity); - return { secret: token, apiKey: this.map(entity) }; + return { ...apiKey, secret: token, apiKey }; } async update(auth: AuthDto, id: string, dto: ApiKeyUpdateDto): Promise { @@ -45,6 +47,24 @@ export class ApiKeyService extends BaseService { return this.map(key); } + async rotate(auth: AuthDto, id: string): Promise { + const existing = await findOrFail(() => this.apiKeyRepository.getById(auth.user.id, id), 'API Key not found'); + + if ( + auth.apiKey && + !isGranted({ requested: existing.permissions as Permission[], current: auth.apiKey.permissions }) + ) { + throw new BadRequestException('Cannot rotate an API Key with permissions you do not have'); + } + + const token = this.cryptoRepository.randomBytesAsText(32); + const hashed = this.cryptoRepository.hashSha256(token); + const newKey = await this.apiKeyRepository.update(auth.user.id, id, { key: hashed }); + const apiKey = this.map(newKey); + + return { ...apiKey, secret: token, apiKey }; + } + async delete(auth: AuthDto, id: string): Promise { const exists = await this.apiKeyRepository.getById(auth.user.id, id); if (!exists) { diff --git a/server/src/services/asset-file.service.ts b/server/src/services/asset-file.service.ts new file mode 100644 index 0000000000..155d2d407c --- /dev/null +++ b/server/src/services/asset-file.service.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AssetFileResponseDto, AssetFileSearchDto, mapAssetFile } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetFileType, CacheControl, JobName, Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; +import { mimeTypes } from 'src/utils/mime-types'; +import { findOrFail } from 'src/utils/misc'; + +@Injectable() +export class AssetFileService extends BaseService { + async search(auth: AuthDto, dto: AssetFileSearchDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }); + const files = await this.assetFileRepository.search(dto); + return files.map((file) => mapAssetFile(file)); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetFileRead, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + return mapAssetFile(file); + } + + async download(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDownload, ids: [id] }); + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + + return new ImmichFileResponse({ + path: file.path, + fileName: getFileNameWithoutExtension(file.path) + getFilenameExtension(file.path), + contentType: mimeTypes.lookup(file.path), + cacheControl: CacheControl.PrivateWithCache, + }); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDelete, ids: [id] }); + + const file = await findOrFail(() => this.assetFileRepository.get(id), 'Asset file'); + // TODO consider implications of allowing sidecar files to be deleted + if (file.type === AssetFileType.Sidecar) { + throw new BadRequestException('Sidecar files cannot be deleted'); + } + + await this.assetFileRepository.delete(id); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.path] } }); + } +} diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 0264484bfc..f5c4304285 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -326,6 +326,7 @@ describe(AssetService.name, () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); + mocks.asset.getById.mockResolvedValueOnce(getForAsset(unlinkedAsset)); mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset)); await sut.update(auth, asset.id, { livePhotoVideoId: null }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 15385df0e0..83cc7a653e 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -65,7 +65,7 @@ export class AssetService extends BaseService { const asset = await this.assetRepository.getById(id, { exifInfo: true, owner: true, - faces: { person: true }, + faces: { person: true, viewingUserId: auth.user.id }, stack: { assets: true }, edits: true, tags: true, @@ -85,7 +85,7 @@ export class AssetService extends BaseService { delete data.owner; } - if (data.ownerId !== auth.user.id || auth.sharedLink) { + if (auth.sharedLink) { data.people = []; } @@ -124,7 +124,7 @@ export class AssetService extends BaseService { throw new BadRequestException('Asset not found'); } - return mapAsset(asset, { auth }); + return this.get(auth, id) as Promise; } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 6ed8ce925a..3c5dab1ed2 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -1,10 +1,10 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { Insertable } from 'kysely'; import sanitize from 'sanitize-filename'; -import { SystemConfig } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { UserAdmin } from 'src/database'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; @@ -12,8 +12,10 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; @@ -73,7 +75,9 @@ export const BASE_SERVICE_DEPENDENCIES = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, + ClusterGroupRepository, ConfigRepository, CronRepository, CryptoRepository, @@ -133,7 +137,9 @@ export class BaseService { protected appRepository: AppRepository, protected assetRepository: AssetRepository, protected assetEditRepository: AssetEditRepository, + protected assetFileRepository: AssetFileRepository, protected assetJobRepository: AssetJobRepository, + protected clusterGroupRepository: ClusterGroupRepository, protected configRepository: ConfigRepository, protected cronRepository: CronRepository, protected cryptoRepository: CryptoRepository, @@ -202,7 +208,9 @@ export class BaseService { ctx.appRepository, ctx.assetRepository, ctx.assetEditRepository, + ctx.assetFileRepository, ctx.assetJobRepository, + ctx.clusterGroupRepository, ctx.configRepository, ctx.cronRepository, ctx.cryptoRepository, @@ -292,7 +300,7 @@ export class BaseService { } } - async createUser(dto: Insertable & { email: string }): Promise { + async createUser(dto: Omit, 'clusterGroupId'> & { email: string }): Promise { const exists = await this.userRepository.getByEmail(dto.email); if (exists) { this.logger.debug('User creation rejected: user already exists'); @@ -306,7 +314,7 @@ export class BaseService { } } - const payload: Insertable = { ...dto }; + const payload: Omit, 'clusterGroupId'> = { ...dto }; if (payload.password) { payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); } @@ -314,7 +322,8 @@ export class BaseService { payload.storageLabel = sanitize(payload.storageLabel.replaceAll('.', '')); } - const user = await this.userRepository.create(payload); + const clusterGroup = await this.clusterGroupRepository.create(); + const user = await this.userRepository.create({ ...payload, clusterGroupId: clusterGroup.id }); await this.eventRepository.emit('UserCreate', user); diff --git a/server/src/services/cluster-group.service.ts b/server/src/services/cluster-group.service.ts new file mode 100644 index 0000000000..bff5fdf7dd --- /dev/null +++ b/server/src/services/cluster-group.service.ts @@ -0,0 +1,90 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { MaybeDuplicate } from 'src/dtos/activity.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + ClusterGroupRequestCreateDto, + ClusterGroupRequestResponseDto, + mapClusterGroupRequest, +} from 'src/dtos/cluster-group.dto'; +import { mapUser, UserResponseDto } from 'src/dtos/user.dto'; +import { Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { findOrFail } from 'src/utils/misc'; + +@Injectable() +export class ClusterGroupService extends BaseService { + async getRequests(auth: AuthDto): Promise { + const requests = await this.clusterGroupRepository.searchRequests({ userId: auth.user.id }); + return requests.map((request) => mapClusterGroupRequest(request)); + } + + async getRequestsForGroup(auth: AuthDto, clusterGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.ClusterGroupRead, ids: [clusterGroupId] }); + + const requests = await this.clusterGroupRepository.searchRequests({ clusterGroupId }); + return requests.map((request) => mapClusterGroupRequest(request)); + } + + async getUsers(auth: AuthDto, clusterGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.ClusterGroupRead, ids: [clusterGroupId] }); + + const users = await this.clusterGroupRepository.getUsers({ clusterGroupId, userId: auth.user.id }); + return users.map((user) => mapUser(user)); + } + + async createRequest( + auth: AuthDto, + clusterGroupId: string, + { userId }: ClusterGroupRequestCreateDto, + ): Promise> { + await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestCreate, ids: [clusterGroupId] }); + + if (userId === auth.user.id) { + throw new BadRequestException('Cannot request to join your own cluster group'); + } + + await findOrFail(() => this.userRepository.get(userId, {}), 'User'); + + const request = await findOrFail( + () => this.clusterGroupRepository.createRequest({ clusterGroupId, userId }), + 'Request', + ); + + if (request.isInserted) { + await this.eventRepository.emit('ClusterGroupRequest', { clusterGroupId, userId, senderName: auth.user.name }); + } + + return { duplicate: !request.isInserted, value: mapClusterGroupRequest(request) }; + } + + async acceptRequest(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestRead, ids: [id] }); + + const request = await findOrFail(() => this.clusterGroupRepository.getRequest(id), 'Request'); + + await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: request.clusterGroupId }); + await this.userRepository.update(auth.user.id, { clusterGroupId: request.clusterGroupId }); + await this.clusterGroupRepository.deleteRequest(request.id); + } + + async deleteRequest(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.ClusterGroupRequestDelete, ids: [id] }); + await this.clusterGroupRepository.deleteRequest(id); + } + + async leave(auth: AuthDto, clusterGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.ClusterGroupLeave, ids: [clusterGroupId] }); + + const hasOtherMembers = await this.clusterGroupRepository.hasOtherMembers({ + clusterGroupId, + userId: auth.user.id, + }); + if (!hasOtherMembers) { + throw new BadRequestException('Cannot leave a cluster group without any other members'); + } + + const clusterGroup = await this.clusterGroupRepository.create(); + await this.personRepository.reassignCluster({ userId: auth.user.id, newClusterId: clusterGroup.id }); + await this.userRepository.update(auth.user.id, { clusterGroupId: clusterGroup.id }); + } +} diff --git a/server/src/services/database-backup.service.spec.ts b/server/src/services/database-backup.service.spec.ts index 37964e7b6f..ac808af8ae 100644 --- a/server/src/services/database-backup.service.spec.ts +++ b/server/src/services/database-backup.service.spec.ts @@ -1,8 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { DateTime } from 'luxon'; import { PassThrough, Readable } from 'node:stream'; -import { defaults, SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum'; import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository'; import { DatabaseBackupService } from 'src/services/database-backup.service'; @@ -565,6 +565,48 @@ describe(DatabaseBackupService.name, () => { `); }); }); + + describe('using an unparsable URL', () => { + beforeEach(() => { + // unix domain socket URLs cannot be parsed by `new URL` + const dbUrl = 'socket://mypg:mypwd@/var/run/postgresql?db=myimmich'; + const configMock = { + getEnv: () => ({ database: { config: { connectionType: 'url', url: dbUrl }, skipMigrations: false } }), + getWorker: () => ImmichWorker.Api, + isDev: () => false, + } as unknown as any; + + sut = new DatabaseBackupService( + mocks.logger as never, + mocks.storage as never, + configMock as never, + mocks.systemMetadata as never, + mocks.process, + mocks.database as never, + mocks.user as never, + mocks.cron as never, + mocks.job as never, + void 0 as never, + ); + }); + + it('should fallback to reasonable defaults', async () => { + await expect(sut.buildPostgresLaunchArguments('pg_dump')).resolves.toMatchInlineSnapshot(` + { + "args": [ + "socket://mypg:mypwd@/var/run/postgresql?db=myimmich", + "--clean", + "--if-exists", + ], + "bin": "/usr/lib/postgresql/14/bin/pg_dump", + "databaseMajorVersion": 14, + "databasePassword": "", + "databaseUsername": "postgres", + "databaseVersion": "14.10 (Debian 14.10-1.pgdg120+1)", + } + `); + }); + }); }); describe('uploadBackup', () => { diff --git a/server/src/services/database-backup.service.ts b/server/src/services/database-backup.service.ts index a277b028a5..d99d0433e5 100644 --- a/server/src/services/database-backup.service.ts +++ b/server/src/services/database-backup.service.ts @@ -129,6 +129,7 @@ export class DatabaseBackupService { const args: string[] = []; let databaseUsername; + let databasePassword; if (isUrlConnection) { if (bin !== 'pg_dump') { @@ -142,16 +143,19 @@ export class DatabaseBackupService { parsedUrl.searchParams.delete('uselibpqcompat'); databaseUsername = parsedUrl.username || parsedUrl.searchParams.get('user'); + databasePassword = parsedUrl.password; url = parsedUrl.href; } // assume typical values if we can't parse URL or not present databaseUsername ??= 'postgres'; + databasePassword ??= ''; args.push(url); } else { databaseUsername = databaseConfig.username; + databasePassword = databaseConfig.password; args.push( '--username', @@ -214,7 +218,7 @@ export class DatabaseBackupService { bin: `/usr/lib/postgresql/${databaseMajorVersion}/bin/${bin}`, args, databaseUsername, - databasePassword: isUrlConnection ? new URL(databaseConfig.url).password : databaseConfig.password, + databasePassword, databaseVersion, databaseMajorVersion, }; @@ -580,7 +584,7 @@ function createSqlOwnerTransformStream(databaseUsername: string) { const DATA_MARKER_START = new TextEncoder().encode('FROM stdin'); const LINE_END = new TextEncoder().encode(';'); - const owner = new TextEncoder().encode(databaseUsername); + const owner = new TextEncoder().encode(`"${databaseUsername}"`); let ownerSequenceIndex = 0; diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 3dc9c0dd03..c73da19543 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -117,6 +117,9 @@ export class DownloadService extends BaseService { void zip.finalize(); - return { stream: zip.stream }; + return { + stream: zip.stream, + disposition: dto.archiveName && `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`, + }; } } diff --git a/server/src/services/hls.service.ts b/server/src/services/hls.service.ts index 94a8a46ace..d2f7133c0d 100644 --- a/server/src/services/hls.service.ts +++ b/server/src/services/hls.service.ts @@ -5,7 +5,7 @@ import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VER import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { ConfigFFmpegDto } from 'src/dtos/config.dto'; import { CacheControl, ImmichWorker, Permission } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -124,7 +124,7 @@ export class HlsService extends BaseService { this.websocketRepository.serverSend('HlsSessionEnd', { sessionId }); } - private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) { + private generateMainPlaylist(sessionId: string, ffmpeg: ConfigFFmpegDto, asset: AssetWithStreamInfo) { const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration; const roundedFps = fps.toFixed(3); const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 766b5979bc..2a45d0308b 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,11 +2,13 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; import { ApiKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; +import { AssetFileService } from 'src/services/asset-file.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuthAdminService } from 'src/services/auth-admin.service'; import { AuthService } from 'src/services/auth.service'; import { CliService } from 'src/services/cli.service'; +import { ClusterGroupService } from 'src/services/cluster-group.service'; import { DatabaseBackupService } from 'src/services/database-backup.service'; import { DatabaseService } from 'src/services/database.service'; import { DownloadService } from 'src/services/download.service'; @@ -55,6 +57,7 @@ export const services = [ ActivityService, AlbumService, ApiService, + AssetFileService, AssetMediaService, AssetService, AuthService, @@ -76,6 +79,7 @@ export const services = [ NotificationService, NotificationAdminService, OcrService, + ClusterGroupService, PartnerService, PersonService, PluginService, diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index a464c9e174..7a7ebb8e02 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -50,7 +50,7 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } }, + item: { name: JobName.PersonGenerateThumbnail, data: { ownerId: 'owner-1', personGroupId: 'person-group-1' } }, jobs: [], }, { @@ -90,6 +90,7 @@ describe(JobService.name, () => { for (const { item, jobs, stub } of tests) { it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => { if (stub) { + mocks.asset.getById.mockResolvedValue(stub[0]); mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue(stub); } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 6d4f04df37..3a9f7e76cb 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -124,11 +124,8 @@ export class JobService extends BaseService { } case JobName.PersonGenerateThumbnail: { - const { id } = item.data; - const person = await this.personRepository.getById(id); - if (person) { - this.websocketRepository.clientSend('on_person_thumbnail', person.ownerId, person.id); - } + const { ownerId, personGroupId } = item.data; + this.websocketRepository.clientSend('on_person_thumbnail', ownerId, personGroupId); break; } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index ae06232605..575de87598 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; -import { defaults, SystemConfig } from 'src/config'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 9e5e0cf4d1..fa405b9d22 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -110,7 +110,7 @@ export class LibraryService extends BaseService { const handler = async (event: string, path: string) => { if (matcher(path)) { - this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`); + this.logger.debug(`File ${event} event received for ${path} in library ${library.id}`); await this.jobRepository.queue({ name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths: [path] }, @@ -121,7 +121,7 @@ export class LibraryService extends BaseService { }; const deletionHandler = async (path: string) => { - this.logger.debug(`File unlink event received for ${path} in library ${library.id}}`); + this.logger.debug(`File unlink event received for ${path} in library ${library.id}`); await this.jobRepository.queue({ name: JobName.LibraryRemoveAsset, data: { libraryId: library.id, paths: [path] }, @@ -444,10 +444,6 @@ export class LibraryService extends BaseService { await this.jobRepository.queue({ name: JobName.LibrarySyncAssetsQueueAll, data: { id } }); } - async queueScanAll() { - await this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: {} }); - } - @OnJob({ name: JobName.LibraryScanQueueAll, queue: QueueName.Library }) async handleQueueScanAll(): Promise { this.logger.log(`Initiating scan of all external libraries...`); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d64e0088eb..48895c4763 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,7 +1,7 @@ import { ShallowDehydrateObject } from 'kysely'; import { OutputInfo } from 'sharp'; -import { SystemConfig } from 'src/config'; import { Exif } from 'src/database'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetFileType, @@ -72,7 +72,7 @@ describe(MediaService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { ownerId: person.ownerId, personGroupId: person.personGroupId }, }, ]); }); @@ -129,7 +129,8 @@ describe(MediaService.name, () => { { name: JobName.PersonGenerateThumbnail, data: { - id: person1.id, + ownerId: person1.ownerId, + personGroupId: person1.personGroupId, }, }, ]); @@ -297,7 +298,12 @@ describe(MediaService.name, () => { expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.PersonFileMigration, + data: { ownerId: person.ownerId, personGroupId: person.personGroupId }, + }, + ]); }); }); @@ -1518,24 +1524,25 @@ describe(MediaService.name, () => { info: { width: 1000, height: 1000 } as OutputInfo, }); - await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: 'owner-1', personGroupId: 'person-group-1' }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.generateThumbnail).toHaveBeenCalled(); }); it('should skip a person not found', async () => { - await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); + await sut.handleGeneratePersonThumbnail({ ownerId: 'owner-1', personGroupId: 'person-group-1' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person without a face asset id', async () => { const person = PersonFactory.create({ faceAssetId: null }); - mocks.person.getById.mockResolvedValue(person); - await sut.handleGeneratePersonThumbnail({ id: person.id }); + await sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); it('should skip a person with face not found', async () => { - await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); + await sut.handleGeneratePersonThumbnail({ ownerId: 'owner-1', personGroupId: 'person-group-1' }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); @@ -1548,9 +1555,14 @@ describe(MediaService.name, () => { const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); + expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, { colorspace: Colorspace.P3, @@ -1581,7 +1593,11 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + thumbnailPath: expect.any(String), + }); }); it('should use preview path if video', async () => { @@ -1593,9 +1609,14 @@ describe(MediaService.name, () => { const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); - expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id); + expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + }); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), { colorspace: Colorspace.P3, @@ -1626,7 +1647,11 @@ describe(MediaService.name, () => { }, expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) }); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + thumbnailPath: expect.any(String), + }); }); it('should generate a thumbnail without going negative', async () => { @@ -1638,7 +1663,9 @@ describe(MediaService.name, () => { const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, { colorspace: Colorspace.P3, @@ -1681,7 +1708,9 @@ describe(MediaService.name, () => { const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, { colorspace: Colorspace.P3, @@ -1724,7 +1753,9 @@ describe(MediaService.name, () => { const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1767,7 +1798,9 @@ describe(MediaService.name, () => { const info = { width: 4624, height: 3080 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, { colorspace: Colorspace.P3, @@ -1814,7 +1847,9 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, { @@ -1857,7 +1892,9 @@ describe(MediaService.name, () => { const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).toHaveBeenCalled(); @@ -1873,7 +1910,9 @@ describe(MediaService.name, () => { const info = { width: 2160, height: 3840 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { @@ -1897,7 +1936,9 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false }); - await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success); + await expect( + sut.handleGeneratePersonThumbnail({ ownerId: person.ownerId, personGroupId: person.personGroupId }), + ).resolves.toBe(JobStatus.Success); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 19b1688e88..9fda932bc1 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,11 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { AssetFile } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; +import { ConfigFFmpegDto, SystemConfig } from 'src/dtos/config.dto'; import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { AssetFileType, AssetType, @@ -91,16 +90,17 @@ export class MediaService extends BaseService { for await (const people of batched(this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }))) { const jobs: JobItem[] = []; for (const person of people) { + const { ownerId, personGroupId } = person; if (!person.faceAssetId) { - const face = await this.personRepository.getRandomFace(person.id); + const face = await this.personRepository.getRandomFace(personGroupId); if (!face) { continue; } - await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + await this.personRepository.update({ ownerId, personGroupId, faceAssetId: face.id }); } - jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } }); + jobs.push({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } }); } await this.jobRepository.queueAll(jobs); @@ -125,7 +125,10 @@ export class MediaService extends BaseService { for await (const people of batched(this.personRepository.getAll())) { await this.jobRepository.queueAll( - people.map((person) => ({ name: JobName.PersonFileMigration, data: { id: person.id } })), + people.map(({ ownerId, personGroupId }) => ({ + name: JobName.PersonFileMigration, + data: { ownerId, personGroupId }, + })), ); } @@ -387,19 +390,22 @@ export class MediaService extends BaseService { } @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) - async handleGeneratePersonThumbnail({ id }: JobOf): Promise { + async handleGeneratePersonThumbnail({ + ownerId, + personGroupId, + }: JobOf): Promise { const { image } = await this.getConfig({ withCache: true }); - const data = await this.personRepository.getDataForThumbnailGenerationJob(id); + const data = await this.personRepository.getDataForThumbnailGenerationJob({ ownerId, personGroupId }); if (!data) { - this.logger.error(`Could not generate person thumbnail for ${id}: missing data`); + this.logger.error(`Could not generate person thumbnail for ${personGroupId}: missing data`); return JobStatus.Failed; } - const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data; + const { x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data; let inputImage: string | Buffer; if (data.type === AssetType.Video) { if (!previewPath) { - this.logger.error(`Could not generate person thumbnail for video ${id}: missing preview path`); + this.logger.error(`Could not generate person thumbnail for video ${personGroupId}: missing preview path`); return JobStatus.Failed; } inputImage = previewPath; @@ -417,7 +423,7 @@ export class MediaService extends BaseService { orientation: Buffer.isBuffer(inputImage) && exifOrientation ? Number(exifOrientation) : undefined, }); - const thumbnailPath = StorageCore.getPersonThumbnailPath({ id, ownerId }); + const thumbnailPath = StorageCore.getPersonThumbnailPath({ ownerId, personGroupId }); this.storageCore.ensureFolders(thumbnailPath); const thumbnailOptions: GenerateThumbnailOptions = { @@ -440,7 +446,7 @@ export class MediaService extends BaseService { }; await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath); - await this.personRepository.update({ id, thumbnailPath }); + await this.personRepository.update({ ownerId, personGroupId, thumbnailPath }); return JobStatus.Success; } @@ -626,7 +632,7 @@ export class MediaService extends BaseService { } private getTranscodeTarget( - config: SystemConfigFFmpegDto, + config: ConfigFFmpegDto, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo, ): TranscodeTarget { @@ -648,7 +654,7 @@ export class MediaService extends BaseService { return TranscodeTarget.None; } - private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean { + private isAudioTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream?: AudioStreamInfo): boolean { if (!stream) { return false; } @@ -671,7 +677,7 @@ export class MediaService extends BaseService { } } - private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean { + private isVideoTranscodeRequired(ffmpegConfig: ConfigFFmpegDto, stream: VideoStreamInfo): boolean { const isScalingEnabled = ffmpegConfig.targetResolution !== 'original'; const targetRes = Number.parseInt(ffmpegConfig.targetResolution); const isLargerThanTargetRes = isScalingEnabled && Math.min(stream.height, stream.width) > targetRes; @@ -703,7 +709,7 @@ export class MediaService extends BaseService { } } - private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { + private isRemuxRequired(ffmpegConfig: ConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { if (ffmpegConfig.transcode === TranscodePolicy.Disabled) { return false; } @@ -828,7 +834,7 @@ export class MediaService extends BaseService { : undefined; const originalDimensions = getDimensions(asset.exifInfo!); - const assetFaces = await this.personRepository.getFaces(asset.id, {}); + const assetFaces = await this.personRepository.getFaces(asset.id, { viewingUserId: asset.ownerId }); const ocrData = await this.ocrRepository.getByAssetId(asset.id, {}); const faceStatuses = checkFaceVisibility(assetFaces, originalDimensions, cropBox); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e..5f1036567c 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -2,7 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored'; import { DateTime } from 'luxon'; import { randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; -import { defaults } from 'src/config'; +import { defaults } from 'src/dtos/config.dto'; import { AssetFileType, AssetType, @@ -17,6 +17,7 @@ import { import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { AssetFactory } from 'test/factories/asset.factory'; +import { PersonGroupFactory } from 'test/factories/person-group.factory'; import { PersonFactory } from 'test/factories/person.factory'; import { videoInfoStub } from 'test/fixtures/media.stub'; import { tagStub } from 'test/fixtures/tag.stub'; @@ -1388,7 +1389,8 @@ describe(MetadataService.name, () => { mockReadTags(faceTags); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([person.id]); + mocks.person.createGroups.mockResolvedValue([PersonGroupFactory.create({ id: person.personGroupId })]); + mocks.person.createAll.mockResolvedValue([person]); mocks.person.update.mockResolvedValue(person); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1411,7 +1413,8 @@ describe(MetadataService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name })); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([person.id]); + mocks.person.createGroups.mockResolvedValue([PersonGroupFactory.create({ id: person.personGroupId })]); + mocks.person.createAll.mockResolvedValue([person]); mocks.person.update.mockResolvedValue(person); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); @@ -1422,7 +1425,7 @@ describe(MetadataService.name, () => { { id: 'random-uuid', assetId: asset.id, - personId: 'random-uuid', + personGroupId: 'random-uuid', imageHeight: 100, imageWidth: 1000, boundingBoxX1: 0, @@ -1435,12 +1438,12 @@ describe(MetadataService.name, () => { [], ); expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, + { ownerId: asset.ownerId, personGroupId: 'random-uuid', faceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { ownerId: asset.ownerId, personGroupId: 'random-uuid' }, }, ]); }); @@ -1452,7 +1455,8 @@ describe(MetadataService.name, () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name })); - mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]); + mocks.person.getDistinctNames.mockResolvedValue([{ personGroupId: person.personGroupId, name: person.name }]); + mocks.person.createGroups.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); mocks.person.update.mockResolvedValue(person); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1464,7 +1468,7 @@ describe(MetadataService.name, () => { { id: 'random-uuid', assetId: asset.id, - personId: person.id, + personGroupId: person.personGroupId, imageHeight: 100, imageWidth: 1000, boundingBoxX1: 0, @@ -1540,7 +1544,8 @@ describe(MetadataService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); mockReadTags(makeFaceTags({ Name: person.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([person.id]); + mocks.person.createGroups.mockResolvedValue([PersonGroupFactory.create({ id: person.personGroupId })]); + mocks.person.createAll.mockResolvedValue([person]); mocks.person.update.mockResolvedValue(person); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); @@ -1553,7 +1558,7 @@ describe(MetadataService.name, () => { { id: 'random-uuid', assetId: asset.id, - personId: 'random-uuid', + personGroupId: 'random-uuid', imageWidth: imgW, imageHeight: imgH, boundingBoxX1: x1, @@ -1566,12 +1571,12 @@ describe(MetadataService.name, () => { [], ); expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, + { ownerId: asset.ownerId, personGroupId: 'random-uuid', faceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { ownerId: asset.ownerId, personGroupId: 'random-uuid' }, }, ]); }, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index a95d1f1497..c6abad7cbc 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -893,7 +893,13 @@ export class MetadataService extends BaseService { } private async applyTaggedFaces( - asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string }, + asset: { + id: string; + ownerId: string; + clusterGroupId: string; + faces: { id: string; sourceType: SourceType }[]; + originalPath: string; + }, tags: ImmichTags, ) { if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) { @@ -902,9 +908,11 @@ export class MetadataService extends BaseService { const facesToAdd: (Insertable & { assetId: string })[] = []; const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); - const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); - const missing: (Insertable & { ownerId: string })[] = []; - const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = []; + const existingNameMap = new Map( + existingNames.map(({ personGroupId, name }) => [name.toLowerCase(), personGroupId]), + ); + const missing: (Insertable & { name: string; personGroupId: string; clusterGroupId: string })[] = []; + const missingWithFaceAsset: { personGroupId: string; ownerId: string; faceAssetId: string }[] = []; const adjustedRegionInfo = this.orientRegionInfo(tags.RegionInfo, tags.Orientation); const imageWidth = adjustedRegionInfo.AppliedToDimensions.W; @@ -916,7 +924,7 @@ export class MetadataService extends BaseService { } const loweredName = region.Name.toLowerCase(); - const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID(); + const personGroupId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID(); const X = Number(region.Area.X); const Y = Number(region.Area.Y); @@ -925,7 +933,7 @@ export class MetadataService extends BaseService { const face = { id: this.cryptoRepository.randomUUID(), - personId, + personGroupId, assetId: asset.id, imageWidth, imageHeight, @@ -938,15 +946,27 @@ export class MetadataService extends BaseService { facesToAdd.push(face); if (!existingNameMap.has(loweredName)) { - missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); - missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id }); + missing.push({ + personGroupId, + ownerId: asset.ownerId, + clusterGroupId: asset.clusterGroupId, + name: region.Name, + }); + missingWithFaceAsset.push({ personGroupId, ownerId: asset.ownerId, faceAssetId: face.id }); } } if (missing.length > 0) { - this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); - const newPersonIds = await this.personRepository.createAll(missing); - const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const); + this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.personGroupId}`)}`); + await this.personRepository.createGroups( + missing.map((item) => ({ id: item.personGroupId, clusterGroupId: asset.clusterGroupId })), + ); + await this.personRepository.createAll(missing); + + const jobs = missing.map( + ({ personGroupId, ownerId }) => + ({ name: JobName.PersonGenerateThumbnail, data: { personGroupId, ownerId } }) as const, + ); await this.jobRepository.queueAll(jobs); } diff --git a/server/src/services/notification-admin.service.spec.ts b/server/src/services/notification-admin.service.spec.ts index c200897719..12fb3e71fb 100644 --- a/server/src/services/notification-admin.service.spec.ts +++ b/server/src/services/notification-admin.service.spec.ts @@ -1,4 +1,4 @@ -import { defaults, SystemConfig } from 'src/config'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { EmailTemplate } from 'src/repositories/email.repository'; import { NotificationService } from 'src/services/notification.service'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/services/notification-admin.service.ts b/server/src/services/notification-admin.service.ts index 2fc4584dca..63c1da0d2e 100644 --- a/server/src/services/notification-admin.service.ts +++ b/server/src/services/notification-admin.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/config.dto'; import { mapNotification, NotificationCreateDto } from 'src/dtos/notification.dto'; -import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { NotificationLevel, NotificationType } from 'src/enum'; import { EmailTemplate } from 'src/repositories/email.repository'; import { BaseService } from 'src/services/base.service'; diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index d6fcefbe6a..47b9b7257a 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -1,5 +1,4 @@ -import { defaults, SystemConfig } from 'src/config'; -import { SystemConfigDto } from 'src/dtos/system-config.dto'; +import { AdminConfigDto, defaults, SystemConfig } from 'src/dtos/config.dto'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { NotificationService } from 'src/services/notification.service'; import { AlbumFactory } from 'test/factories/album.factory'; @@ -100,7 +99,7 @@ describe(NotificationService.name, () => { it('skips smtp validation with DTO when there are no changes', async () => { const oldConfig = { ...configs.smtpEnabled }; - const newConfig = configs.smtpEnabled as SystemConfigDto; + const newConfig = configs.smtpEnabled as AdminConfigDto; await expect(sut.onConfigValidate({ oldConfig, newConfig })).resolves.not.toThrow(); expect(mocks.email.verifySmtp).not.toHaveBeenCalled(); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index a650b466ba..6fcbd81915 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -3,6 +3,7 @@ import { OnEvent, OnJob } from 'src/decorators'; import { MapAlbumDto } from 'src/dtos/album.dto'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfigSmtpDto } from 'src/dtos/config.dto'; import { mapNotification, NotificationDeleteAllDto, @@ -11,7 +12,6 @@ import { NotificationUpdateAllDto, NotificationUpdateDto, } from 'src/dtos/notification.dto'; -import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AssetFileType, JobName, @@ -169,7 +169,7 @@ export class NotificationService extends BaseService { return; } - const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]); + const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId], userId); if (asset) { this.websocketRepository.clientSend( 'on_asset_update', @@ -236,6 +236,20 @@ export class NotificationService extends BaseService { await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId, senderName } }); } + @OnEvent({ name: 'ClusterGroupRequest' }) + async onClusterGroupRequest({ clusterGroupId, userId, senderName }: ArgOf<'ClusterGroupRequest'>) { + const item = await this.notificationRepository.create({ + userId, + type: NotificationType.ClusterGroupRequest, + level: NotificationLevel.Info, + title: 'Cluster Group Request', + description: `${senderName} asked you to join their cluster group`, + data: JSON.stringify({ clusterGroupId }), + }); + + this.websocketRepository.clientSend('on_notification', userId, mapNotification(item)); + } + @OnEvent({ name: 'SessionDelete' }) onSessionDelete({ sessionId }: ArgOf<'SessionDelete'>) { // after the response is sent diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index e6a11786af..2f56f04548 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -2,12 +2,12 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; -import { FaceSearchResult } from 'src/repositories/search.repository'; import { PersonService } from 'src/services/person.service'; import { ImmichFileResponse } from 'src/utils/file'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { AuthFactory } from 'test/factories/auth.factory'; +import { PersonGroupFactory } from 'test/factories/person-group.factory'; import { PersonFactory } from 'test/factories/person.factory'; import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; @@ -17,6 +17,7 @@ import { getForAsset, getForAssetFace, getForDetectedFaces, + getForFaceSearch, getForFacialRecognitionJob, } from 'test/mappers'; import { newDate, newUuid } from 'test/small.factory'; @@ -49,9 +50,9 @@ describe(PersonService.name, () => { total: 2, hidden: 1, people: [ - expect.objectContaining({ id: person.id, isHidden: false }), + expect.objectContaining({ id: person.personGroupId, isHidden: false }), expect.objectContaining({ - id: hiddenPerson.id, + id: hiddenPerson.personGroupId, isHidden: true, }), ], @@ -76,10 +77,10 @@ describe(PersonService.name, () => { hidden: 1, people: [ expect.objectContaining({ - id: isFavorite.id, + id: isFavorite.personGroupId, isFavorite: true, }), - expect.objectContaining({ id: person.id, isFavorite: false }), + expect.objectContaining({ id: person.personGroupId, isFavorite: false }), ], }); expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, { @@ -92,9 +93,9 @@ describe(PersonService.name, () => { it('should require person.read permission', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.person.getByGroupId.mockResolvedValue(person); + await expect(sut.getById(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should throw a bad request when person is not found', async () => { @@ -108,11 +109,16 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id })); - expect(mocks.person.getById).toHaveBeenCalledWith(person.id); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.person.getByGroupId.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + await expect(sut.getById(auth, person.personGroupId)).resolves.toEqual( + expect.objectContaining({ id: person.personGroupId }), + ); + expect(mocks.person.getByGroupId).toHaveBeenCalledWith({ + ownerId: auth.user.id, + personGroupId: person.personGroupId, + }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); }); @@ -121,10 +127,10 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); + mocks.person.getByGroupId.mockResolvedValue(person); + await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should throw an error when personId is invalid', async () => { @@ -140,27 +146,27 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create({ thumbnailPath: '' }); - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException); + mocks.person.getByGroupId.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + await expect(sut.getThumbnail(auth, person.personGroupId)).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should serve the thumbnail', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual( + mocks.person.getByGroupId.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + await expect(sut.getThumbnail(auth, person.personGroupId)).resolves.toEqual( new ImmichFileResponse({ path: person.thumbnailPath, contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithoutCache, }), ); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); }); @@ -169,10 +175,12 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException); + mocks.person.getByGroupId.mockResolvedValue(person); + await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should throw an error when personId is invalid', async () => { @@ -188,26 +196,32 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create({ name: 'Person 1' }); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); - await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual( - expect.objectContaining({ id: person.id, name: 'Person 1' }), + await expect(sut.update(auth, person.personGroupId, { name: 'Person 1' })).resolves.toEqual( + expect.objectContaining({ id: person.personGroupId, name: 'Person 1' }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + name: 'Person 1', + }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it("should update a person's date of birth", async () => { const auth = AuthFactory.create(); const person = PersonFactory.create({ birthDate: new Date('1976-06-30') }); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); - await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({ - id: person.id, + await expect(sut.update(auth, person.personGroupId, { birthDate: '1976-06-30' })).resolves.toEqual({ + id: person.personGroupId, name: person.name, birthDate: '1976-06-30', thumbnailPath: person.thumbnailPath, @@ -215,40 +229,54 @@ describe(PersonService.name, () => { isFavorite: false, updatedAt: expect.any(String), }); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: '1976-06-30' }); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + birthDate: '1976-06-30', + }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should update a person visibility', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create({ isHidden: true }); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); - await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual( + await expect(sut.update(auth, person.personGroupId, { isHidden: true })).resolves.toEqual( expect.objectContaining({ isHidden: true }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + isHidden: true, + }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should update a person favorite status', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create({ isFavorite: true }); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); - await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual( + await expect(sut.update(auth, person.personGroupId, { isFavorite: true })).resolves.toEqual( expect.objectContaining({ isFavorite: true }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + isFavorite: true, + }); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it("should update a person's thumbnailPath", async () => { @@ -256,37 +284,44 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.update.mockResolvedValue(person); mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); - await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual( - expect.objectContaining({ id: person.id }), + await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: face.assetId })).resolves.toEqual( + expect.objectContaining({ id: person.personGroupId }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id }); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + faceAssetId: face.id, + }); expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ assetId: face.assetId, - personId: person.id, + personGroupId: person.personGroupId, }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { ownerId: person.ownerId, personGroupId: person.personGroupId }, }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should throw an error when the face feature assetId is invalid', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getByGroupId.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); - await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException); + await expect(sut.update(auth, person.personGroupId, { featureFaceAssetId: '-1' })).rejects.toThrow( + BadRequestException, + ); expect(mocks.person.update).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); }); @@ -320,8 +355,8 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - mocks.person.getById.mockResolvedValue(person); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]); mocks.person.reassignFace.mockResolvedValue(1); @@ -331,15 +366,15 @@ describe(PersonService.name, () => { mocks.person.update.mockResolvedValue(person); await expect( - sut.reassignFaces(auth, person.id, { - data: [{ personId: person.id, assetId: face.assetId }], + sut.reassignFaces(auth, person.personGroupId, { + data: [{ personId: person.personGroupId, assetId: face.assetId }], }), ).resolves.toBeDefined(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { ownerId: person.ownerId, personGroupId: person.personGroupId }, }, ]); }); @@ -381,21 +416,21 @@ describe(PersonService.name, () => { const person = PersonFactory.create({ faceAssetId: null }); const featureFace = AssetFaceFactory.create({ assetId: asset.id, - personId: person.id, + personGroupId: person.personGroupId, sourceType: SourceType.Manual, }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.getRandomFace.mockResolvedValue(featureFace); mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id }); await expect( sut.createFace(auth, { assetId: asset.id, - personId: person.id, + personId: person.personGroupId, imageHeight: 500, imageWidth: 400, x: 10, @@ -408,7 +443,7 @@ describe(PersonService.name, () => { expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true }); expect(mocks.person.createAssetFace).toHaveBeenCalledWith({ assetId: asset.id, - personId: person.id, + personGroupId: person.personGroupId, imageHeight: 500, imageWidth: 400, boundingBoxX1: 10, @@ -417,10 +452,17 @@ describe(PersonService.name, () => { boundingBoxY2: 130, sourceType: SourceType.Manual, }); - expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id }); + expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.personGroupId); + expect(mocks.person.update).toHaveBeenCalledWith({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + faceAssetId: featureFace.id, + }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.PersonGenerateThumbnail, data: { id: person.id } }, + { + name: JobName.PersonGenerateThumbnail, + data: { ownerId: person.ownerId, personGroupId: person.personGroupId }, + }, ]); }); @@ -430,14 +472,14 @@ describe(PersonService.name, () => { const person = PersonFactory.create({ faceAssetId: newUuid() }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getByGroupId.mockResolvedValue(person); await expect( sut.createFace(auth, { assetId: asset.id, - personId: person.id, + personId: person.personGroupId, imageHeight: 500, imageWidth: 400, x: 10, @@ -459,12 +501,12 @@ describe(PersonService.name, () => { const person = PersonFactory.create({ faceAssetId: null }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); await expect( sut.createFace(auth, { assetId: asset.id, - personId: person.id, + personId: person.personGroupId, imageHeight: 500, imageWidth: 400, x: 10, @@ -483,11 +525,11 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create()); - await sut.createNewFeaturePhoto([person.id]); + await sut.createNewFeaturePhoto([person]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, - data: { id: person.id }, + data: { ownerId: person.ownerId, personGroupId: person.personGroupId }, }, ]); }); @@ -498,20 +540,22 @@ describe(PersonService.name, () => { const face = AssetFaceFactory.create(); const person = PersonFactory.create(); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(person); - await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({ - birthDate: person.birthDate, - isHidden: person.isHidden, - isFavorite: person.isFavorite, - id: person.id, - name: person.name, - thumbnailPath: person.thumbnailPath, - updatedAt: expect.any(String), - }); + mocks.person.getByGroupId.mockResolvedValue(person); + await expect(sut.reassignFacesById(AuthFactory.create(), person.personGroupId, { id: face.id })).resolves.toEqual( + { + birthDate: person.birthDate, + isHidden: person.isHidden, + isFavorite: person.isFavorite, + id: person.personGroupId, + name: person.name, + thumbnailPath: person.thumbnailPath, + updatedAt: expect.any(String), + }, + ); expect(mocks.job.queue).not.toHaveBeenCalledWith(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); @@ -521,12 +565,12 @@ describe(PersonService.name, () => { const face = AssetFaceFactory.create(); const person = PersonFactory.create(); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getByGroupId.mockResolvedValue(person); await expect( - sut.reassignFacesById(AuthFactory.create(), person.id, { + sut.reassignFacesById(AuthFactory.create(), person.personGroupId, { id: face.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -537,13 +581,16 @@ describe(PersonService.name, () => { }); describe('createPerson', () => { - it('should create a new person', async () => { + it('should create a new person in a new group', async () => { const auth = AuthFactory.create(); + const group = PersonGroupFactory.create(); - mocks.person.create.mockResolvedValue(PersonFactory.create()); + mocks.person.createGroup.mockResolvedValue(group); + mocks.person.create.mockResolvedValue(PersonFactory.create({ personGroupId: group.id })); await expect(sut.create(auth, {})).resolves.toBeDefined(); - expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id }); + expect(mocks.person.createGroup).toHaveBeenCalledWith(auth.user.id); + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id, personGroupId: group.id }); }); }); @@ -552,10 +599,12 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.delete.mockResolvedValue([person]); await sut.handlePersonCleanup(); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined); + expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith(); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); }); }); @@ -592,11 +641,13 @@ describe(PersonService.name, () => { mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.delete.mockResolvedValue([person]); await sut.handleQueueDetectFaces({ force: true }); expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning }); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined); + expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith(); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); @@ -614,7 +665,7 @@ describe(PersonService.name, () => { await sut.handleQueueDetectFaces({ force: undefined }); - expect(mocks.person.delete).not.toHaveBeenCalled(); + expect(mocks.person.deleteGroups).not.toHaveBeenCalled(); expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); expect(mocks.person.vacuum).not.toHaveBeenCalled(); expect(mocks.storage.unlink).not.toHaveBeenCalled(); @@ -637,6 +688,7 @@ describe(PersonService.name, () => { mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset])); mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.delete.mockResolvedValue([person]); mocks.person.deleteFaces.mockResolvedValue(); await sut.handleQueueDetectFaces({ force: true }); @@ -648,7 +700,8 @@ describe(PersonService.name, () => { data: { id: asset.id }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined); + expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith(); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); }); @@ -703,7 +756,7 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({}); expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ - personId: null, + personGroupId: null, sourceType: SourceType.MachineLearning, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -769,7 +822,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState); expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ - personId: null, + personGroupId: null, sourceType: SourceType.MachineLearning, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -817,6 +870,7 @@ describe(PersonService.name, () => { mocks.person.getAll.mockReturnValue(makeStream([face.person!, person])); mocks.person.getAllFaces.mockReturnValue(makeStream([face])); mocks.person.getAllWithoutFaces.mockResolvedValue([person]); + mocks.person.delete.mockResolvedValue([person]); mocks.person.unassignFaces.mockResolvedValue(); await sut.handleQueueRecognizeFaces({ force: true }); @@ -829,7 +883,8 @@ describe(PersonService.name, () => { data: { id: face.id, deferred: false }, }, ]); - expect(mocks.person.delete).toHaveBeenCalledWith([person.id]); + expect(mocks.person.delete).toHaveBeenCalledWith([person.personGroupId], undefined); + expect(mocks.person.deleteEmptyGroups).toHaveBeenCalledWith(); expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); }); @@ -878,7 +933,7 @@ describe(PersonService.name, () => { const face = AssetFaceFactory.create({ assetId: asset.id }); mocks.crypto.randomUUID.mockReturnValue(face.id); mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face)); - mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]); + mocks.search.searchFaces.mockResolvedValue([getForFaceSearch(face, 0.7)]); mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset)); mocks.person.refreshFaces.mockResolvedValue(); @@ -1014,20 +1069,21 @@ describe(PersonService.name, () => { const [noPerson1, noPerson2, primaryFace, face] = [ AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create(), - AssetFaceFactory.from().person().build(), - AssetFaceFactory.from().person().build(), + AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(), + AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(), ]; const faces = [ - { ...noPerson1, distance: 0 }, - { ...primaryFace, distance: 0.2 }, - { ...noPerson2, distance: 0.3 }, - { ...face, distance: 0.4 }, - ] as FaceSearchResult[]; + getForFaceSearch(noPerson1, 0), + getForFaceSearch(primaryFace, 0.2), + getForFaceSearch(noPerson2, 0.3), + getForFaceSearch(face, 0.4), + ]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.getByGroupId.mockResolvedValue(primaryFace.person!); mocks.person.create.mockResolvedValue(primaryFace.person!); await sut.handleRecognizeFaces({ id: noPerson1.id }); @@ -1036,11 +1092,11 @@ describe(PersonService.name, () => { expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([noPerson1.id]), - newPersonId: primaryFace.person!.id, + newPersonGroupId: primaryFace.person!.personGroupId, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([face.id]), - newPersonId: primaryFace.person!.id, + newPersonGroupId: primaryFace.person!.personGroupId, }); }); @@ -1048,19 +1104,20 @@ describe(PersonService.name, () => { const asset = AssetFactory.create(); const [noPerson, face, faceWithBirthDate] = [ AssetFaceFactory.create({ assetId: asset.id }), - AssetFaceFactory.from().person().build(), - AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(), + AssetFaceFactory.from().person({ ownerId: asset.ownerId, birthDate: newDate() }).build(), ]; const faces = [ - { ...noPerson, distance: 0 }, - { ...face, distance: 0.2 }, - { ...faceWithBirthDate, distance: 0.3 }, - ] as FaceSearchResult[]; + getForFaceSearch(noPerson, 0), + getForFaceSearch(face, 0.2), + getForFaceSearch(faceWithBirthDate, 0.3), + ]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.getByGroupId.mockResolvedValue(face.person!); mocks.person.create.mockResolvedValue(face.person!); await sut.handleRecognizeFaces({ id: noPerson.id }); @@ -1069,11 +1126,11 @@ describe(PersonService.name, () => { expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([noPerson.id]), - newPersonId: face.person!.id, + newPersonGroupId: face.person!.personGroupId, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([face.id]), - newPersonId: face.person!.id, + newPersonGroupId: face.person!.personGroupId, }); }); @@ -1081,19 +1138,20 @@ describe(PersonService.name, () => { const asset = AssetFactory.create(); const [noPerson, face, faceWithBirthDate] = [ AssetFaceFactory.create({ assetId: asset.id }), - AssetFaceFactory.from().person().build(), - AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + AssetFaceFactory.from().person({ ownerId: asset.ownerId }).build(), + AssetFaceFactory.from().person({ ownerId: asset.ownerId, birthDate: newDate() }).build(), ]; const faces = [ - { ...noPerson, distance: 0 }, - { ...faceWithBirthDate, distance: 0.2 }, - { ...face, distance: 0.3 }, - ] as FaceSearchResult[]; + getForFaceSearch(noPerson, 0), + getForFaceSearch(faceWithBirthDate, 0.2), + getForFaceSearch(face, 0.3), + ]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.getByGroupId.mockResolvedValue(faceWithBirthDate.person!); mocks.person.create.mockResolvedValue(face.person!); await sut.handleRecognizeFaces({ id: noPerson.id }); @@ -1102,11 +1160,11 @@ describe(PersonService.name, () => { expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([noPerson.id]), - newPersonId: faceWithBirthDate.person!.id, + newPersonGroupId: faceWithBirthDate.person!.personGroupId, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([face.id]), - newPersonId: faceWithBirthDate.person!.id, + newPersonGroupId: faceWithBirthDate.person!.personGroupId, }); }); @@ -1115,32 +1173,64 @@ describe(PersonService.name, () => { const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; const person = PersonFactory.create(); - const faces = [ - { ...noPerson1, distance: 0 }, - { ...noPerson2, distance: 0.3 }, - ] as FaceSearchResult[]; + const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.3)]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.createGroup.mockResolvedValue(PersonGroupFactory.create({ id: person.personGroupId })); mocks.person.create.mockResolvedValue(person); await sut.handleRecognizeFaces({ id: noPerson1.id }); + expect(mocks.person.createGroup).toHaveBeenCalledWith(asset.ownerId); expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: asset.ownerId, faceAssetId: noPerson1.id, + personGroupId: person.personGroupId, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: [noPerson1.id], - newPersonId: person.id, + newPersonGroupId: person.personGroupId, + }); + }); + + it('should create a person in the matched group when the match belongs to another user', async () => { + const asset = AssetFactory.create(); + const [noPerson, otherOwnerFace] = [ + AssetFaceFactory.create({ assetId: asset.id }), + AssetFaceFactory.from().person().build(), + ]; + const person = PersonFactory.create({ + ownerId: asset.ownerId, + personGroupId: otherOwnerFace.person!.personGroupId, + }); + + const faces = [getForFaceSearch(noPerson, 0), getForFaceSearch(otherOwnerFace, 0.2)]; + + mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); + mocks.search.searchFaces.mockResolvedValue(faces); + mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson, asset)); + mocks.person.create.mockResolvedValue(person); + + await sut.handleRecognizeFaces({ id: noPerson.id }); + + expect(mocks.person.createGroup).not.toHaveBeenCalled(); + expect(mocks.person.create).toHaveBeenCalledWith({ + ownerId: asset.ownerId, + faceAssetId: noPerson.id, + personGroupId: otherOwnerFace.person!.personGroupId, + }); + expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ + faceIds: [noPerson.id], + newPersonGroupId: otherOwnerFace.person!.personGroupId, }); }); it('should not queue face with no matches', async () => { const asset = AssetFactory.create(); const face = AssetFaceFactory.create({ assetId: asset.id }); - const faces = [{ ...face, distance: 0 }] as FaceSearchResult[]; + const faces = [getForFaceSearch(face, 0)]; mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(face, asset)); @@ -1158,10 +1248,7 @@ describe(PersonService.name, () => { const asset = AssetFactory.create(); const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; - const faces = [ - { ...noPerson1, distance: 0 }, - { ...noPerson2, distance: 0.4 }, - ] as FaceSearchResult[]; + const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.4)]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValue(faces); @@ -1183,10 +1270,7 @@ describe(PersonService.name, () => { const asset = AssetFactory.create(); const [noPerson1, noPerson2] = [AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.create()]; - const faces = [ - { ...noPerson1, distance: 0 }, - { ...noPerson2, distance: 0.4 }, - ] as FaceSearchResult[]; + const faces = [getForFaceSearch(noPerson1, 0), getForFaceSearch(noPerson2, 0.4)]; mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 3 } } }); mocks.search.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); @@ -1202,141 +1286,26 @@ describe(PersonService.name, () => { }); }); - describe('mergePerson', () => { - it('should require person.write and person.merge permission', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf( - BadRequestException, - ); - - expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); - - expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); - }); - - it('should merge two people without smart merge', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); - - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ - { id: mergePerson.id, success: true }, - ]); - - expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: person.id, - oldPersonId: mergePerson.id, - }); - - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); - }); - - it('should merge two people with smart merge', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [ - PersonFactory.create({ name: undefined }), - PersonFactory.create({ name: 'Merge person' }), - ]; - - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name }); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); - - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ - { id: mergePerson.id, success: true }, - ]); - - expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: person.id, - oldPersonId: mergePerson.id, - }); - - expect(mocks.person.update).toHaveBeenCalledWith({ - id: person.id, - name: mergePerson.name, - }); - - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); - }); - - it('should throw an error when the primary person is not found', async () => { - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - - await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( - BadRequestException, - ); - - expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); - }); - - it('should handle invalid merge ids', async () => { - const auth = AuthFactory.create(); - const person = PersonFactory.create(); - - mocks.person.getById.mockResolvedValueOnce(person); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown'])); - - await expect(sut.mergePerson(auth, person.id, { ids: ['unknown'] })).resolves.toEqual([ - { id: 'unknown', success: false, error: BulkIdErrorReason.NOT_FOUND }, - ]); - - expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); - expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); - }); - - it('should handle an error reassigning faces', async () => { - const auth = AuthFactory.create(); - const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); - mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); - - await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([ - { id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN }, - ]); - - expect(mocks.person.delete).not.toHaveBeenCalled(); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); - }); - }); - describe('getStatistics', () => { it('should get correct number of person', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getByGroupId.mockResolvedValue(person); mocks.person.getStatistics.mockResolvedValue({ assets: 3 }); - mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 }); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.personGroupId])); + await expect(sut.getStatistics(auth, person.personGroupId)).resolves.toEqual({ assets: 3 }); + expect(mocks.person.getStatistics).toHaveBeenCalledWith(person.personGroupId, auth.user.id); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); it('should require person.read permission', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); - await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); - expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); + mocks.person.getByGroupId.mockResolvedValue(person); + await expect(sut.getStatistics(auth, person.personGroupId)).rejects.toBeInstanceOf(BadRequestException); + expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.personGroupId])); }); }); @@ -1363,11 +1332,5 @@ describe(PersonService.name, () => { it('should not map person if person is null', () => { expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull(); }); - - it('should not map person if person does not match auth user id', () => { - expect( - mapFaces(getForAssetFace(AssetFaceFactory.from().person().build()), AuthFactory.create()).person, - ).toBeNull(); - }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 7c08c36e17..54c84eb22e 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { Insertable, Updateable } from 'kysely'; +import { Insertable, Selectable, Updateable } from 'kysely'; import { Person } from 'src/database'; import { Chunked, OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; @@ -34,9 +34,10 @@ import { VectorIndex, } from 'src/enum'; import { BoundingBox } from 'src/repositories/machine-learning.repository'; -import { UpdateFacesData } from 'src/repositories/person.repository'; +import { PersonId, UpdateFacesData } from 'src/repositories/person.repository'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; +import { PersonTable } from 'src/schema/tables/person.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { getDimensions } from 'src/utils/asset.util'; @@ -45,6 +46,8 @@ import { mimeTypes } from 'src/utils/mime-types'; import { batched, findOrFail, isFacialRecognitionEnabled } from 'src/utils/misc'; import { Point, transformPoints } from 'src/utils/transform'; +const personKey = ({ ownerId, personGroupId }: PersonId) => `${ownerId}/${personGroupId}`; + @Injectable() export class PersonService extends BaseService { async getAll(auth: AuthDto, dto: PersonSearchDto): Promise { @@ -56,7 +59,10 @@ export class PersonService extends BaseService { }; if (closestPersonId) { - const person = await this.personRepository.getById(closestPersonId); + const person = await this.personRepository.getByGroupId({ + ownerId: auth.user.id, + personGroupId: closestPersonId, + }); if (!person?.faceAssetId) { throw new NotFoundException('Person not found'); } @@ -76,92 +82,94 @@ export class PersonService extends BaseService { }; } - async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); - const person = await this.findOrFail(personId); + async reassignFaces(auth: AuthDto, personGroupId: string, dto: AssetFaceUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); + const person = await this.findOrFail(auth, personGroupId); const result: PersonResponseDto[] = []; - const changeFeaturePhoto: string[] = []; + const changeFeaturePhoto = new Map(); for (const data of dto.data) { - const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); + const faces = await this.personRepository.getFacesByIds( + [{ personGroupId: data.personId, assetId: data.assetId }], + { viewingUserId: auth.user.id }, + ); for (const face of faces) { await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] }); if (person.faceAssetId === null) { - changeFeaturePhoto.push(person.id); + changeFeaturePhoto.set(personKey(person), person); } if (face.person && face.person.faceAssetId === face.id) { - changeFeaturePhoto.push(face.person.id); + changeFeaturePhoto.set(personKey(face.person), face.person); } - await this.personRepository.reassignFace(face.id, personId); + await this.personRepository.reassignFace(face.id, person.personGroupId); } result.push(mapPerson(person)); } - if (changeFeaturePhoto.length > 0) { - // Remove duplicates - await this.createNewFeaturePhoto([...new Set(changeFeaturePhoto)]); + if (changeFeaturePhoto.size > 0) { + await this.createNewFeaturePhoto(changeFeaturePhoto.values().toArray()); } return result; } - async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); + async reassignFacesById(auth: AuthDto, personGroupId: string, dto: FaceDto): Promise { + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] }); - const face = await this.personRepository.getFaceById(dto.id); - const person = await this.findOrFail(personId); + const face = await this.personRepository.getFaceById(dto.id, { viewingUserId: auth.user.id }); + const person = await this.findOrFail(auth, personGroupId); - await this.personRepository.reassignFace(face.id, personId); + await this.personRepository.reassignFace(face.id, person.personGroupId); if (person.faceAssetId === null) { - await this.createNewFeaturePhoto([person.id]); + await this.createNewFeaturePhoto([person]); } if (face.person && face.person.faceAssetId === face.id) { - await this.createNewFeaturePhoto([face.person.id]); + await this.createNewFeaturePhoto([face.person]); } - return mapPerson(await this.findOrFail(personId)); + return mapPerson(await this.findOrFail(auth, personGroupId)); } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); - const faces = await this.personRepository.getFaces(dto.id); + const faces = await this.personRepository.getFaces(dto.id, { viewingUserId: auth.user.id, isVisible: true }); const asset = await this.assetRepository.getForFaces(dto.id); const assetDimensions = getDimensions(asset); return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions)); } - async createNewFeaturePhoto(changeFeaturePhoto: string[]) { + async createNewFeaturePhoto(changeFeaturePhoto: PersonId[]) { this.logger.debug( `Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`, ); const jobs: JobItem[] = []; - for (const personId of changeFeaturePhoto) { - const assetFace = await this.personRepository.getRandomFace(personId); + for (const { ownerId, personGroupId } of changeFeaturePhoto) { + const assetFace = await this.personRepository.getRandomFace(personGroupId); if (assetFace) { - await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); - jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } }); + await this.personRepository.update({ ownerId, personGroupId, faceAssetId: assetFace.id }); + jobs.push({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } }); } } await this.jobRepository.queueAll(jobs); } - async getById(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - return mapPerson(await this.findOrFail(id)); + async getById(auth: AuthDto, personGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); + return mapPerson(await this.findOrFail(auth, personGroupId)); } - async getStatistics(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - return this.personRepository.getStatistics(id); + async getStatistics(auth: AuthDto, personGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); + return this.personRepository.getStatistics(personGroupId, auth.user.id); } - async getThumbnail(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - const person = await this.personRepository.getById(id); + async getThumbnail(auth: AuthDto, personGroupId: string): Promise { + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [personGroupId] }); + const person = await this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId }); if (!person || !person.thumbnailPath) { throw new NotFoundException(); } @@ -174,8 +182,10 @@ export class PersonService extends BaseService { } async create(auth: AuthDto, dto: PersonCreateDto): Promise { + const group = await this.personRepository.createGroup(auth.user.id); const person = await this.personRepository.create({ ownerId: auth.user.id, + personGroupId: group.id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden, @@ -186,15 +196,16 @@ export class PersonService extends BaseService { return mapPerson(person); } - async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); + async update(auth: AuthDto, personGroupId: string, dto: PersonUpdateDto): Promise { + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); + const { ownerId } = await this.findOrFail(auth, personGroupId); const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto; // TODO: set by faceId directly let faceId: string | undefined; if (assetId) { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] }); - const face = await this.personRepository.getForFeatureFaceUpdate({ personId: id, assetId }); + const face = await this.personRepository.getForFeatureFaceUpdate({ personGroupId, assetId }); if (!face) { throw new BadRequestException('Invalid assetId for feature face or asset is offline'); } @@ -203,7 +214,8 @@ export class PersonService extends BaseService { } const person = await this.personRepository.update({ - id, + ownerId, + personGroupId, faceAssetId: faceId, name, birthDate, @@ -213,7 +225,7 @@ export class PersonService extends BaseService { }); if (assetId) { - await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id } }); + await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { ownerId, personGroupId } }); } return mapPerson(person); @@ -245,21 +257,32 @@ export class PersonService extends BaseService { async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise { await this.requireAccess({ auth, permission: Permission.PersonDelete, ids }); - const people = await this.personRepository.getForPeopleDelete(ids); - await this.removeAllPeople(people); + await this.removeAllPersonGroups(ids, auth.user.id); } @Chunked() - private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) { + private async removeAllPersonGroups(groupIds: string[], ownerId?: string) { + if (groupIds.length === 0) { + return; + } + + const people = await this.personRepository.delete(groupIds, ownerId); await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); - await this.personRepository.delete(people.map((person) => person.id)); - this.logger.debug(`Deleted ${people.length} people`); + await this.personRepository.deleteEmptyGroups(); + this.logger.debug(`Deleted ${groupIds.length} people`); } @OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask }) async handlePersonCleanup(): Promise { + // each step can leave the next one something to clean up, so the order matters const people = await this.personRepository.getAllWithoutFaces(); - await this.removeAllPeople(people); + await this.removeAllPersonGroups(people.map((person) => person.personGroupId)); + + const personGroups = await this.personRepository.deleteEmptyGroups(); + const clusterGroups = await this.personRepository.deleteOrphanedClusterGroups(); + + this.logger.debug(`Deleted ${personGroups} empty person groups and ${clusterGroups} orphaned cluster groups`); + return JobStatus.Success; } @@ -429,7 +452,7 @@ export class PersonService extends BaseService { const lastRun = new Date().toISOString(); const faces = this.personRepository.getAllFaces( - force ? undefined : { personId: null, sourceType: SourceType.MachineLearning }, + force ? undefined : { personGroupId: null, sourceType: SourceType.MachineLearning }, ); for await (const batch of batched(faces)) { await this.jobRepository.queueAll( @@ -465,13 +488,14 @@ export class PersonService extends BaseService { return JobStatus.Failed; } - if (face.personId) { + if (face.personGroupId) { this.logger.debug(`Face ${id} already has a person assigned`); return JobStatus.Skipped; } + const { ownerId, clusterGroupId } = face.asset; const matches = await this.searchRepository.searchFaces({ - userIds: [face.asset.ownerId], + clusterGroupId, embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: machineLearning.facialRecognition.minFaces, @@ -495,10 +519,10 @@ export class PersonService extends BaseService { return JobStatus.Skipped; } - let personId = matches.find((match) => match.personId)?.personId; - if (!personId) { - const matchWithPerson = await this.searchRepository.searchFaces({ - userIds: [face.asset.ownerId], + let personGroupId = matches.find((match) => match.personGroupId)?.personGroupId; + if (!personGroupId) { + const [matchWithPerson] = await this.searchRepository.searchFaces({ + clusterGroupId, embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: 1, @@ -506,29 +530,38 @@ export class PersonService extends BaseService { minBirthDate: new Date(face.asset.fileCreatedAt), }); - if (matchWithPerson.length > 0) { - personId = matchWithPerson[0].personId; + personGroupId = matchWithPerson?.personGroupId ?? undefined; + } + + if (!personGroupId && isCore) { + const group = await this.personRepository.createGroup(ownerId); + personGroupId = group.id; + this.logger.log(`Created person group ${personGroupId} for face ${id}`); + } + + if (personGroupId) { + const person = await this.personRepository.getByGroupId({ ownerId, personGroupId }); + if (person) { + this.logger.debug(`Face ${id} matched person ${person.personGroupId}`); + } else { + await this.personRepository.create({ ownerId, faceAssetId: face.id, personGroupId }); + this.logger.log(`Created person for face ${id} in group ${personGroupId}`); + await this.jobRepository.queue({ + name: JobName.PersonGenerateThumbnail, + data: { ownerId, personGroupId }, + }); } - } - if (isCore && !personId) { - this.logger.log(`Creating new person for face ${id}`); - const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); - await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); - personId = newPerson.id; - } - - if (personId) { - this.logger.debug(`Assigning face ${id} to person ${personId}`); - await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); + this.logger.debug(`Assigning face ${id} to person group ${personGroupId}`); + await this.personRepository.reassignFaces({ faceIds: [id], newPersonGroupId: personGroupId }); } return JobStatus.Success; } @OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration }) - async handlePersonMigration({ id }: JobOf): Promise { - const person = await this.personRepository.getById(id); + async handlePersonMigration({ ownerId, personGroupId }: JobOf): Promise { + const person = await this.personRepository.getByGroupId({ ownerId, personGroupId }); if (!person) { return JobStatus.Failed; } @@ -538,15 +571,13 @@ export class PersonService extends BaseService { return JobStatus.Success; } - async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise { + async mergePerson(auth: AuthDto, personGroupId: string, dto: MergePersonDto): Promise { const mergeIds = dto.ids; - if (mergeIds.includes(id)) { + if (mergeIds.includes(personGroupId)) { throw new BadRequestException('Cannot merge a person into themselves'); } - await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); - let primaryPerson = await this.findOrFail(id); - const primaryName = primaryPerson.name || primaryPerson.id; + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personGroupId] }); const results: BulkIdResponseDto[] = []; @@ -556,52 +587,71 @@ export class PersonService extends BaseService { ids: mergeIds, }); - for (const mergeId of mergeIds) { + let primaryPerson: Selectable | undefined; + + for (const mergePerson of await this.personRepository.getForMergePerson(mergeIds)) { + const mergeId = mergePerson.personGroupId; const hasAccess = allowedIds.has(mergeId); if (!hasAccess) { results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION }); continue; } - try { - const mergePerson = await this.personRepository.getById(mergeId); - if (!mergePerson) { - results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); + if (!primaryPerson || primaryPerson.ownerId !== mergePerson.ownerId) { + primaryPerson = await this.personRepository.getByGroupId({ ownerId: mergePerson.ownerId, personGroupId }); + if (!primaryPerson) { continue; } + } - const update: Updateable & { id: string } = { id: primaryPerson.id }; - if (!primaryPerson.name && mergePerson.name) { - update.name = mergePerson.name; - } + const changes: Updateable = {}; + if (!primaryPerson.name && mergePerson.name) { + changes.name = mergePerson.name; + } - if (!primaryPerson.birthDate && mergePerson.birthDate) { - update.birthDate = mergePerson.birthDate; - } + if (!primaryPerson.birthDate && mergePerson.birthDate) { + changes.birthDate = mergePerson.birthDate; + } - if (Object.keys(update).length > 1) { - primaryPerson = await this.personRepository.update(update); - } + if ( + (mergePerson.name && mergePerson.name !== primaryPerson.name) || + (mergePerson.birthDate && mergePerson.birthDate !== primaryPerson.birthDate) + ) { + continue; + } - const mergeName = mergePerson.name || mergePerson.id; - const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; - this.logger.log(`Merging ${mergeName} into ${primaryName}`); + if (Object.keys(changes).length > 0) { + primaryPerson = await this.personRepository.update({ + ownerId: primaryPerson.ownerId, + personGroupId: primaryPerson.personGroupId, + ...changes, + }); + } + const mergeName = mergePerson.name || mergePerson.personGroupId; + const mergeData: UpdateFacesData = { + oldPersonGroupId: mergeId, + newPersonGroupId: primaryPerson.personGroupId, + ownerId: primaryPerson.ownerId, + }; + this.logger.log(`Merging ${mergeName} into ${primaryPerson.name || primaryPerson.personGroupId}`); + + try { await this.personRepository.reassignFaces(mergeData); - await this.removeAllPeople([mergePerson]); + await this.removeAllPersonGroups([mergeId], primaryPerson.ownerId); - this.logger.log(`Merged ${mergeName} into ${primaryName}`); + this.logger.log(`Merged ${mergeName} into ${primaryPerson.name || primaryPerson.personGroupId}`); results.push({ id: mergeId, success: true }); - } catch (error: Error | any) { - this.logger.error(`Unable to merge ${mergeId} into ${id}: ${error}`, error?.stack); + } catch (error: any) { + this.logger.error(`Unable to merge ${mergeId} into ${personGroupId}: ${error}`, error?.stack); results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN }); } } return results; } - private findOrFail(id: string) { - return findOrFail(() => this.personRepository.getById(id), 'Person'); + private findOrFail(auth: AuthDto, personGroupId: string) { + return findOrFail(() => this.personRepository.getByGroupId({ ownerId: auth.user.id, personGroupId }), 'Person'); } // TODO return a asset face response @@ -613,7 +663,7 @@ export class PersonService extends BaseService { const [asset, person] = await Promise.all([ this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true }), - this.findOrFail(dto.personId), + this.findOrFail(auth, dto.personId), ]); if (!asset) { @@ -661,7 +711,7 @@ export class PersonService extends BaseService { } await this.personRepository.createAssetFace({ - personId: dto.personId, + personGroupId: person.personGroupId, assetId: dto.assetId, imageHeight: dto.imageHeight, imageWidth: dto.imageWidth, @@ -673,7 +723,7 @@ export class PersonService extends BaseService { }); if (!person.faceAssetId) { - await this.createNewFeaturePhoto([person.id]); + await this.createNewFeaturePhoto([person]); } } diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 5643c5eced..3490c634e6 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { defaults, SystemConfig } from 'src/config'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { ImmichWorker, JobName, QueueCommand, QueueName } from 'src/enum'; import { QueueService } from 'src/services/queue.service'; import { factory } from 'test/small.factory'; diff --git a/server/src/services/queue.service.ts b/server/src/services/queue.service.ts index 1f0bf6c76b..7130146c15 100644 --- a/server/src/services/queue.service.ts +++ b/server/src/services/queue.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; import { OnEvent } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { mapQueueLegacy, mapQueuesLegacy, diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index c0f5a3614d..bc59a4ad72 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -250,7 +250,13 @@ describe(SearchService.name, () => { ); expect(mocks.search.searchSmart).toHaveBeenCalledWith( { page: 1, size: 100 }, - { query: 'test', embedding: '[1, 2, 3]', userIds: [authStub.user1.user.id], visibility: 'not-locked' }, + { + query: 'test', + embedding: '[1, 2, 3]', + userIds: [authStub.user1.user.id], + viewingUserId: authStub.user1.user.id, + visibility: 'not-locked', + }, ); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index c99b340dae..1612b0e29c 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -44,12 +44,14 @@ export class SearchService extends BaseService { const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options); const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks( cities.items.map(({ data }) => data), + auth.user.id, ); const cityItems = cityAssets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) })); const recents = await this.assetRepository.getRecentlyCreatedAssetIds(auth.user.id, options.maxFields); const recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks( recents.items.map((item) => item.data), + auth.user.id, ); const recentItems = recentAssets.map((asset) => ({ value: asset.createdAt.toISOString(), @@ -92,6 +94,7 @@ export class SearchService extends BaseService { checksum, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, + viewingUserId: auth.user.id, orderDirection: dto.order ?? AssetOrder.Desc, }, ); @@ -109,6 +112,7 @@ export class SearchService extends BaseService { ...dto, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, + viewingUserId: auth.user.id, }); } @@ -122,6 +126,7 @@ export class SearchService extends BaseService { ...dto, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, + viewingUserId: auth.user.id, }); return items.map((item) => mapAsset(item, { auth })); } @@ -136,6 +141,7 @@ export class SearchService extends BaseService { ...dto, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), userIds, + viewingUserId: auth.user.id, }); return items.map((item) => mapAsset(item, { auth })); } @@ -180,6 +186,7 @@ export class SearchService extends BaseService { { ...dto, userIds: await userIds, + viewingUserId: auth.user.id, embedding, visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'), }, diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index b4b1af35d6..82f53bcf38 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -160,6 +160,7 @@ describe(ServerService.name, () => { await expect(sut.getSystemConfig()).resolves.toEqual({ loginPageMessage: '', oauthButtonText: 'Login with OAuth', + oauthAccountManagementUrl: '', trashDays: 30, userDeleteDelay: 7, isInitialized: false, diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 57342f9509..6732099fab 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -120,6 +120,7 @@ export class ServerService extends BaseService { trashDays: config.trash.days, userDeleteDelay: config.user.deleteDelay, oauthButtonText: config.oauth.buttonText, + oauthAccountManagementUrl: config.oauth.accountManagementUrl, isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index 6bd0a3c9b2..354672a6e5 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -1,4 +1,4 @@ -import { SystemConfig } from 'src/config'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetFileType, AssetVisibility, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { SmartInfoService } from 'src/services/smart-info.service'; import { getCLIPModelInfo } from 'src/utils/misc'; diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 19a1774492..e147d3da7f 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { SystemConfig } from 'src/config'; +import { SystemConfig } from 'src/dtos/config.dto'; import { OnEvent, OnJob } from 'src/decorators'; import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 8f11a1dfa2..72d8802e1b 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,5 +1,5 @@ import { Stats } from 'node:fs'; -import { defaults, SystemConfig } from 'src/config'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { AssetPathType, AssetType, JobStatus } from 'src/enum'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { AlbumFactory } from 'test/factories/album.factory'; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index de731f46c6..c2aaf986eb 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; -import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; +import { ConfigTemplateStorageOptionDto } from 'src/dtos/config.dto'; import { AssetFileType, AssetPathType, @@ -129,7 +129,7 @@ export class StorageTemplateService extends BaseService { } } - getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { + getStorageTemplateOptions(): ConfigTemplateStorageOptionDto { return { ...storageTokens, presetOptions: storagePresets }; } diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts new file mode 100644 index 0000000000..ff505c1e67 --- /dev/null +++ b/server/src/services/sync.service.spec.ts @@ -0,0 +1,78 @@ +import { Writable } from 'node:stream'; +import { SyncEntityType } from 'src/enum'; +import { send } from 'src/services/sync.service'; +import { serialize } from 'src/utils/sync'; + +type TestStream = { + stream: Writable; + chunks: string[]; + flushNext: () => void; + pendingCount: () => number; +}; + +const createTestStream = (highWaterMark: number): TestStream => { + const chunks: string[] = []; + const pendingCallbacks: Array<() => void> = []; + + const stream = new Writable({ + highWaterMark, + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()); + pendingCallbacks.push(callback); + }, + }); + + return { + stream, + chunks, + flushNext: () => pendingCallbacks.shift()?.(), + pendingCount: () => pendingCallbacks.length, + }; +}; + +describe('send', () => { + const item = { + type: SyncEntityType.SyncCompleteV1 as const, + data: {}, + ids: ['now-id'] as [string], + }; + + it('resolves immediately when the stream has capacity', async () => { + // A large highWaterMark means write() never signals backpressure for a + // single small item. + const { stream, chunks, flushNext } = createTestStream(1024 * 1024); + + const sendPromise = send(stream, item); + flushNext(); + await sendPromise; + + expect(chunks).toEqual([serialize(item)]); + }); + + it('waits for the drain event before resolving when the stream signals backpressure', async () => { + // A tiny highWaterMark means the very first write already exceeds + // capacity, so write() returns false and send() must wait for 'drain'. + const { stream, chunks, flushNext, pendingCount } = createTestStream(1); + + let resolved = false; + const sendPromise = send(stream, item).then(() => { + resolved = true; + }); + + // Let any pending microtasks run; send() should still be waiting on the + // underlying write to complete and 'drain' to fire — it must not resolve + // just because write() was called. + await Promise.resolve(); + await Promise.resolve(); + expect(resolved).toBe(false); + expect(pendingCount()).toBe(1); + + // Completing the write lets the stream's internal buffer drop back below + // highWaterMark, which is what triggers the 'drain' event. + flushNext(); + await sendPromise; + + expect(resolved).toBe(true); + expect(chunks).toEqual([serialize(item)]); + }); +}); diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3842b1503..c9b98c5d66 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { Insertable } from 'kysely'; import { DateTime, Duration } from 'luxon'; +import { once } from 'node:events'; import { Writable } from 'node:stream'; import { OnJob } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -42,12 +43,17 @@ const isEntityBackfillComplete = (createId: string, checkpoint: SyncAck | undefi const getStartId = (createId: string, checkpoint: SyncAck | undefined): string | undefined => createId === checkpoint?.updateId ? checkpoint?.extraId : undefined; -const send = (response: Writable, item: SerializeOptions) => { - response.write(serialize(item)); +export const send = async ( + response: Writable, + item: SerializeOptions, +) => { + if (!response.write(serialize(item))) { + await once(response, 'drain'); + } }; -const sendEntityBackfillCompleteAck = (response: Writable, ackType: SyncEntityType, id: string) => { - send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType, ids: [id, COMPLETE_ID] }); +const sendEntityBackfillCompleteAck = async (response: Writable, ackType: SyncEntityType, id: string) => { + await send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType, ids: [id, COMPLETE_ID] }); }; export const SYNC_TYPES_ORDER = [ @@ -141,7 +147,7 @@ export class SyncService extends BaseService { const isPendingSyncReset = await this.sessionRepository.isPendingSyncReset(session.id); if (isPendingSyncReset) { - send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); + await send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); response.end(); return; } @@ -150,7 +156,7 @@ export class SyncService extends BaseService { const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)])); if (this.needsFullSync(checkpointMap)) { - send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); + await send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} }); response.end(); return; } @@ -201,7 +207,7 @@ export class SyncService extends BaseService { await handler(); } - send(response, { type: SyncEntityType.SyncCompleteV1, ids: [nowId], data: {} }); + await send(response, { type: SyncEntityType.SyncCompleteV1, ids: [nowId], data: {} }); response.end(); } @@ -221,6 +227,7 @@ export class SyncService extends BaseService { await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold); await this.syncRepository.partner.cleanupAuditTable(pruneThreshold); await this.syncRepository.person.cleanupAuditTable(pruneThreshold); + await this.syncRepository.personGroup.cleanupAuditTable(pruneThreshold); await this.syncRepository.stack.cleanupAuditTable(pruneThreshold); await this.syncRepository.user.cleanupAuditTable(pruneThreshold); await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold); @@ -242,7 +249,11 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.AuthUserV1; const upserts = this.syncRepository.authUser.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, profileImagePath, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } }); + await send(response, { + type: upsertType, + ids: [updateId], + data: { ...data, hasProfileImage: !!profileImagePath }, + }); } } @@ -250,13 +261,17 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.UserDeleteV1; const deletes = this.syncRepository.user.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.UserV1; const upserts = this.syncRepository.user.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, profileImagePath, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } }); + await send(response, { + type: upsertType, + ids: [updateId], + data: { ...data, hasProfileImage: !!profileImagePath }, + }); } } @@ -264,13 +279,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PartnerDeleteV1; const deletes = this.syncRepository.partner.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.PartnerV1; const upserts = this.syncRepository.partner.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -282,13 +297,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AssetDeleteV1; const deletes = this.syncRepository.asset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetV2; const upserts = this.syncRepository.asset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -307,7 +322,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PartnerAssetDeleteV1; const deletes = this.syncRepository.partnerAsset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.PartnerAssetBackfillV2; @@ -334,14 +349,14 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { + await send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data), }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (partners.length > 0) { await this.upsertBackfillCheckpoint({ @@ -353,7 +368,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.partnerAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -361,7 +376,7 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.AssetExifV1; const upserts = this.syncRepository.assetExif.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -370,13 +385,13 @@ export class SyncService extends BaseService { const deletes = this.syncRepository.assetEdit.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetEditV1; const upserts = this.syncRepository.assetEdit.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -411,10 +426,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [partner.createId, updateId], data }); + await send(response, { type: backfillType, ids: [partner.createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, partner.createId); + await sendEntityBackfillCompleteAck(response, backfillType, partner.createId); } } else if (partners.length > 0) { await this.upsertBackfillCheckpoint({ @@ -426,7 +441,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.partnerAssetExif.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -434,14 +449,14 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumDeleteV1; const deletes = this.syncRepository.album.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AlbumV1; const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { const albumUsers = await this.syncRepository.album.getAlbumUsers(data.id); - send(response, { + await send(response, { type: upsertType, ids: [updateId], // TODO: return null instead of '' in v4 @@ -454,14 +469,18 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumDeleteV1; const deletes = this.syncRepository.album.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AlbumV2; const upserts = this.syncRepository.album.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { // TODO: return null instead of '' in v4 - send(response, { type: upsertType, ids: [updateId], data: { ...data, description: data.description ?? '' } }); + await send(response, { + type: upsertType, + ids: [updateId], + data: { ...data, description: data.description ?? '' }, + }); } } @@ -474,7 +493,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumUserDeleteV1; const deletes = this.syncRepository.albumUser.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.AlbumUserBackfillV1; @@ -501,10 +520,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data }); + await send(response, { type: backfillType, ids: [createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -516,7 +535,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.albumUser.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -559,10 +578,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data) }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -578,7 +597,7 @@ export class SyncService extends BaseService { createCheckpoint, ); for await (const { updateId, ...data } of updates) { - send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -586,7 +605,7 @@ export class SyncService extends BaseService { let isFirst = true; for await (const { updateId, ...data } of creates) { if (isFirst) { - send(response, { + await send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType: SyncEntityType.AlbumAssetUpdateV2, @@ -594,7 +613,7 @@ export class SyncService extends BaseService { }); isFirst = false; } - send(response, { type: createType, ids: [updateId], data: mapSyncAssetV2(data) }); + await send(response, { type: createType, ids: [updateId], data: mapSyncAssetV2(data) }); } } @@ -630,10 +649,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data }); + await send(response, { type: backfillType, ids: [createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -649,7 +668,7 @@ export class SyncService extends BaseService { createCheckpoint, ); for await (const { updateId, ...data } of updates) { - send(response, { type: updateType, ids: [updateId], data }); + await send(response, { type: updateType, ids: [updateId], data }); } } @@ -657,7 +676,7 @@ export class SyncService extends BaseService { let isFirst = true; for await (const { updateId, ...data } of creates) { if (isFirst) { - send(response, { + await send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType: SyncEntityType.AlbumAssetExifUpdateV1, @@ -665,7 +684,7 @@ export class SyncService extends BaseService { }); isFirst = false; } - send(response, { type: createType, ids: [updateId], data }); + await send(response, { type: createType, ids: [updateId], data }); } } @@ -678,7 +697,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AlbumToAssetDeleteV1; const deletes = this.syncRepository.albumToAsset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.AlbumToAssetBackfillV1; @@ -705,10 +724,10 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { type: backfillType, ids: [createId, updateId], data }); + await send(response, { type: backfillType, ids: [createId, updateId], data }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (albums.length > 0) { await this.upsertBackfillCheckpoint({ @@ -720,7 +739,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.albumToAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -728,13 +747,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.MemoryDeleteV1; const deletes = this.syncRepository.memory.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.MemoryV1; const upserts = this.syncRepository.memory.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -742,13 +761,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.MemoryToAssetDeleteV1; const deletes = this.syncRepository.memoryToAsset.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.MemoryToAssetV1; const upserts = this.syncRepository.memoryToAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -756,13 +775,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.StackDeleteV1; const deletes = this.syncRepository.stack.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.StackV1; const upserts = this.syncRepository.stack.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -775,7 +794,7 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PartnerStackDeleteV1; const deletes = this.syncRepository.partnerStack.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const backfillType = SyncEntityType.PartnerStackBackfillV1; @@ -802,14 +821,14 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of backfill) { - send(response, { + await send(response, { type: backfillType, ids: [createId, updateId], data, }); } - sendEntityBackfillCompleteAck(response, backfillType, createId); + await sendEntityBackfillCompleteAck(response, backfillType, createId); } } else if (partners.length > 0) { await this.upsertBackfillCheckpoint({ @@ -821,7 +840,7 @@ export class SyncService extends BaseService { const upserts = this.syncRepository.partnerStack.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -829,13 +848,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.PersonDeleteV1; const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.PersonV1; const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -849,13 +868,13 @@ export class SyncService extends BaseService { const deleteType = SyncEntityType.AssetFaceDeleteV1; const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetFaceV2; const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -864,14 +883,14 @@ export class SyncService extends BaseService { const deletes = this.syncRepository.userMetadata.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.UserMetadataV1; const upserts = this.syncRepository.userMetadata.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -888,7 +907,7 @@ export class SyncService extends BaseService { ); for await (const { id, ...data } of deletes) { - send(response, { type: deleteType, ids: [id], data }); + await send(response, { type: deleteType, ids: [id], data }); } const upsertType = SyncEntityType.AssetMetadataV1; @@ -898,7 +917,7 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } @@ -915,7 +934,7 @@ export class SyncService extends BaseService { ); for await (const row of deletes) { - send(response, { type: deleteType, ids: [row.id], data: row }); + await send(response, { type: deleteType, ids: [row.id], data: row }); } const upsertType = SyncEntityType.AssetOcrV1; @@ -925,7 +944,7 @@ export class SyncService extends BaseService { ); for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + await send(response, { type: upsertType, ids: [updateId], data }); } } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 08851da96a..e6ada2f0b0 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -1,6 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { defaults, SystemConfig } from 'src/config'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; +import { defaults, SystemConfig } from 'src/dtos/config.dto'; import { AudioCodec, Colorspace, @@ -10,6 +9,7 @@ import { LogLevel, OAuthTokenEndpointAuthMethod, QueueName, + ReleaseChannel, ToneMapping, TranscodeHardwareAcceleration, TranscodePolicy, @@ -154,6 +154,7 @@ const updatedConfig = Object.freeze({ enabled: true, }, oauth: { + accountManagementUrl: '', autoLaunch: true, autoRegister: true, buttonText: 'Login with OAuth', @@ -269,7 +270,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', () => { mocks.systemMetadata.get.mockResolvedValue(partialConfig); - expect(sut.getDefaults()).toEqual(defaults); + expect(sut.getAdminConfigDefaults()).toEqual(defaults); expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); }); @@ -278,7 +279,7 @@ describe(SystemConfigService.name, () => { it('should return the default config', async () => { mocks.systemMetadata.get.mockResolvedValue({}); - await expect(sut.getSystemConfig()).resolves.toEqual(defaults); + await expect(sut.getAdminConfig()).resolves.toEqual(defaults); }); it('should merge the overrides', async () => { @@ -289,14 +290,14 @@ describe(SystemConfigService.name, () => { user: { deleteDelay: 15 }, }); - await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig); }); it('should load the config from a json file', async () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -305,7 +306,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { twoPass: 'false' } })); - await expect(sut.getSystemConfig()).resolves.toMatchObject({ + await expect(sut.getAdminConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ twoPass: false }), }); }); @@ -314,7 +315,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ ffmpeg: { threads: '42' } })); - await expect(sut.getSystemConfig()).resolves.toMatchObject({ + await expect(sut.getAdminConfig()).resolves.toMatchObject({ ffmpeg: expect.objectContaining({ threads: 42 }), }); }); @@ -325,7 +326,7 @@ describe(SystemConfigService.name, () => { JSON.stringify({ library: { scan: { cronExpression: '0 0 */3 * *' } } }), ); - await expect(sut.getSystemConfig()).resolves.toMatchObject({ + await expect(sut.getAdminConfig()).resolves.toMatchObject({ library: { scan: { enabled: true, @@ -339,7 +340,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ oauth: { issuerUrl: 'accounts.google.com' } })); - await expect(sut.getSystemConfig()).rejects.toThrow( + await expect(sut.getAdminConfig()).rejects.toThrow( '[oauth.issuerUrl] Issuer URL must be an empty string or a valid URL', ); }); @@ -348,7 +349,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({ library: { scan: { cronExpression: 'foo' } } })); - await expect(sut.getSystemConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression'); + await expect(sut.getAdminConfig()).rejects.toThrow('[library.scan.cronExpression] Invalid cron expression'); }); it('should log errors with the config file', async () => { @@ -356,7 +357,7 @@ describe(SystemConfigService.name, () => { mocks.systemMetadata.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`); - await expect(sut.getSystemConfig()).rejects.toBeInstanceOf(Error); + await expect(sut.getAdminConfig()).rejects.toBeInstanceOf(Error); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); expect(mocks.logger.error).toHaveBeenCalledTimes(2); @@ -380,7 +381,7 @@ describe(SystemConfigService.name, () => { `; mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); - await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig); + await expect(sut.getAdminConfig()).resolves.toEqual(updatedConfig); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.yaml'); }); @@ -389,7 +390,7 @@ describe(SystemConfigService.name, () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.getSystemConfig()).resolves.toEqual(defaults); + await expect(sut.getAdminConfig()).resolves.toEqual(defaults); expect(mocks.systemMetadata.readFile).toHaveBeenCalledWith('immich-config.json'); }); @@ -399,7 +400,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { machineLearning: { urls: ['immich_machine_learning'] } }; mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getSystemConfig(); + const config = await sut.getAdminConfig(); expect(config.machineLearning.urls).toEqual(['immich_machine_learning']); }); @@ -420,7 +421,7 @@ describe(SystemConfigService.name, () => { const partialConfig = { server: { externalDomain } }; mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); - const config = await sut.getSystemConfig(); + const config = await sut.getAdminConfig(); expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app'); }); } @@ -432,7 +433,7 @@ describe(SystemConfigService.name, () => { `; mocks.systemMetadata.readFile.mockResolvedValue(partialConfig); - await sut.getSystemConfig(); + await sut.getAdminConfig(); expect(mocks.logger.warn).toHaveBeenCalled(); }); @@ -467,12 +468,12 @@ describe(SystemConfigService.name, () => { mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(test.config)); if (test.throws) { - await expect(sut.getSystemConfig()).rejects.toThrow(test.throws); + await expect(sut.getAdminConfig()).rejects.toThrow(test.throws); } else if (test.warn) { - await sut.getSystemConfig(); + await sut.getAdminConfig(); expect(mocks.logger.warn).toHaveBeenCalled(); } else { - const config = await sut.getSystemConfig(); + const config = await sut.getAdminConfig(); test.check!(config); } }); @@ -482,7 +483,7 @@ describe(SystemConfigService.name, () => { describe('updateConfig', () => { it('should update the config and emit an event', async () => { mocks.systemMetadata.get.mockResolvedValue(partialConfig); - await expect(sut.updateSystemConfig(updatedConfig)).resolves.toEqual(updatedConfig); + await expect(sut.updateAdminConfig(updatedConfig)).resolves.toEqual(updatedConfig); expect(mocks.event.emit).toHaveBeenCalledWith( 'ConfigUpdate', expect.objectContaining({ oldConfig: expect.any(Object), newConfig: updatedConfig }), @@ -492,7 +493,7 @@ describe(SystemConfigService.name, () => { it('should throw an error if a config file is in use', async () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify({})); - await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.updateAdminConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index faa4f8d423..055a21f1c5 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,8 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import _ from 'lodash'; -import { defaults } from 'src/config'; import { OnEvent } from 'src/decorators'; -import { mapConfig, SystemConfigDto } from 'src/dtos/system-config.dto'; +import { + AdminConfigDto, + defaults, + mapAdminConfig, + mapPublicConfig, + mapUserConfig, + PublicConfigDto, + UserConfigDto, +} from 'src/dtos/config.dto'; import { BootstrapEventPriority } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; @@ -22,13 +29,31 @@ export class SystemConfigService extends BaseService { this.machineLearningRepository.teardown(); } - async getSystemConfig(): Promise { + async getAdminConfig(): Promise { const config = await this.getConfig({ withCache: false }); - return mapConfig(config); + return mapAdminConfig(config); } - getDefaults(): SystemConfigDto { - return mapConfig(defaults); + getAdminConfigDefaults(): AdminConfigDto { + return mapAdminConfig(defaults); + } + + async getUserConfig(): Promise { + const config = await this.getConfig({ withCache: false }); + return mapUserConfig(config); + } + + getUserConfigDefaults(): UserConfigDto { + return mapUserConfig(defaults); + } + + async getPublicConfig(): Promise { + const config = await this.getConfig({ withCache: false }); + return mapPublicConfig(config); + } + + getPublicConfigDefaults(): PublicConfigDto { + return mapPublicConfig(defaults); } @OnEvent({ name: 'ConfigInit', priority: -100 }) @@ -56,7 +81,7 @@ export class SystemConfigService extends BaseService { } } - async updateSystemConfig(dto: SystemConfigDto): Promise { + async updateAdminConfig(dto: AdminConfigDto): Promise { const { configFile } = this.configRepository.getEnv(); if (configFile) { throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); @@ -75,7 +100,7 @@ export class SystemConfigService extends BaseService { await this.eventRepository.emit('ConfigUpdate', { newConfig, oldConfig }); - return mapConfig(newConfig); + return mapAdminConfig(newConfig); } async getCustomCss(): Promise { diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 3cd442b6a7..08a9b00104 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -93,7 +93,7 @@ export class TagService extends BaseService { const results = await this.tagRepository.upsertAssetIds(items); for (const assetId of new Set(results.map((item) => item.assetId))) { await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return { count: results.length }; @@ -114,7 +114,7 @@ export class TagService extends BaseService { } await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return results; diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index ca668b6068..76a2ef0c67 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -19,9 +19,12 @@ describe(TimelineService.name, () => { await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual( expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]), ); - expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ - userIds: [authStub.admin.user.id], - }); + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith( + { + userIds: [authStub.admin.user.id], + }, + authStub.admin, + ); }); it('should pass bbox options to repository when all bbox fields are provided', async () => { @@ -36,10 +39,13 @@ describe(TimelineService.name, () => { }, }); - expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ - userIds: [authStub.admin.user.id], - bbox: { west: -70, south: -30, east: 120, north: 55 }, - }); + expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith( + { + userIds: [authStub.admin.user.id], + bbox: { west: -70, south: -30, east: 120, north: 55 }, + }, + authStub.admin, + ); }); }); diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index d043267745..197b331cc3 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -12,7 +12,7 @@ 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 await this.assetRepository.getTimeBuckets(timeBucketOptions); + return await this.assetRepository.getTimeBuckets(timeBucketOptions, auth); } // pre-jsonified response diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 49aefaa870..c52ae54221 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -53,6 +53,7 @@ describe(UserAdminService.name, () => { name: userStub.user1.name, storageLabel: 'label', password: expect.anything(), + clusterGroupId: expect.any(String), }); }); }); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 0044730cee..b5fc6764f2 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -1,8 +1,7 @@ import { DateTime } from 'luxon'; import { SemVer } from 'semver'; -import { defaults } from 'src/config'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; -import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum'; +import { defaults } from 'src/dtos/config.dto'; +import { CronJob, JobName, JobStatus, ReleaseChannel, SystemMetadataKey } from 'src/enum'; import { VersionService } from 'src/services/version.service'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index f1abeedb30..85bf579fbd 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -4,8 +4,16 @@ import semver, { SemVer } from 'semver'; import { serverVersion } from 'src/constants'; import { OnEvent, OnJob } from 'src/decorators'; import { ReleaseEventV1, ReleaseType, ServerVersionResponseDto } from 'src/dtos/server.dto'; -import { ReleaseChannel } from 'src/dtos/system-config.dto'; -import { CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum'; +import { + CronJob, + DatabaseLock, + ImmichWorker, + JobName, + JobStatus, + QueueName, + ReleaseChannel, + SystemMetadataKey, +} from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { VersionCheckMetadata } from 'src/types'; diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 318f36a4b6..193fab16de 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -13,6 +13,7 @@ import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { TagBulkAssetsDto } from 'src/dtos/tag.dto'; import { BootstrapEventPriority, DatabaseLock, @@ -21,12 +22,14 @@ import { JobName, JobStatus, QueueName, + WorkflowResult, WorkflowType, } from 'src/enum'; import { ArgOf } from 'src/repositories/event.repository'; import { AlbumService } from 'src/services/album.service'; import { AssetService } from 'src/services/asset.service'; import { BaseService } from 'src/services/base.service'; +import { TagService } from 'src/services/tag.service'; import { JobOf } from 'src/types'; const dummy = () => { @@ -36,7 +39,7 @@ const dummy = () => { }; type ExecuteOptions = { - read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData }>; + read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData; entityId?: string }>; write: (auth: AuthDto, changes: WorkflowChanges) => Promise; }; @@ -69,6 +72,7 @@ export class WorkflowExecutionService extends BaseService { this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); const albumService = BaseService.create(AlbumService, this); + const tagService = BaseService.create(TagService, this); const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args)); const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args)); @@ -106,6 +110,9 @@ export class WorkflowExecutionService extends BaseService { throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); }); + const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) => + tagService.bulkTagAssets(authDto, ...args), + ); const functions = { searchAlbums, @@ -113,6 +120,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum, addAssetsToAlbums, httpRequest, + bulkTagAssets, }; const stubs: typeof functions = { @@ -121,6 +129,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum: dummy, addAssetsToAlbums: dummy, httpRequest: dummy, + bulkTagAssets: dummy, }; const plugins = await this.pluginRepository.getForLoad(); @@ -309,6 +318,11 @@ export class WorkflowExecutionService extends BaseService { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); } + @OnEvent({ name: 'AssetTag' }) + onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) { + return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged }); + } + private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { const items = await this.workflowRepository.search({ userId, trigger }); await this.jobRepository.queueAll( @@ -332,6 +346,7 @@ export class WorkflowExecutionService extends BaseService { return { data: { asset } as any, authUserId: asset.ownerId, + entityId: asset.id, }; }, write: async (auth, changes) => { @@ -399,11 +414,13 @@ export class WorkflowExecutionService extends BaseService { return; } - try { - const { read, write } = handler; - const readResult = await read(type); - let data = readResult.data; - for (const step of workflow.steps) { + const { read, write } = handler; + const readResult = await read(type); + let data = readResult.data; + const runId = crypto.randomUUID(); + + for (const step of workflow.steps) { + try { const payload: WorkflowEventPayload = { trigger: workflow.trigger, type, @@ -454,14 +471,45 @@ export class WorkflowExecutionService extends BaseService { const shouldContinue = result?.workflow?.continue ?? true; if (!shouldContinue) { - break; - } - } + if (workflow.logging) { + await this.workflowRepository.log({ + workflowId, + result: WorkflowResult.Halted, + workflowStepId: step.id, + triggerDataId: readResult.entityId, + runId, + }); + } - this.logger.debug(`Workflow ${workflowId} executed successfully`); - } catch (error) { - this.logger.error(`Error executing workflow ${workflowId}:`, error); - return JobStatus.Failed; + this.logger.debug(`Workflow ${workflowId} run ${runId} stopped on step ${step.id}`); + return; + } + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId} run ${runId}:`, error); + + if (workflow.logging) { + await this.workflowRepository.log({ + workflowId, + result: WorkflowResult.Error, + workflowStepId: step.id, + triggerDataId: readResult.entityId, + runId, + }); + } + + return JobStatus.Failed; + } } + + if (workflow.logging) { + await this.workflowRepository.log({ + workflowId, + result: WorkflowResult.Completed, + triggerDataId: readResult.entityId, + runId, + }); + } + + this.logger.debug(`Workflow ${workflowId} run ${runId} executed successfully`); } } diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index 1d4ecb89a0..e68e6aa94e 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -5,6 +5,8 @@ import { mapWorkflow, mapWorkflowShare, WorkflowCreateDto, + WorkflowGetLogsDto, + WorkflowLogEntryDto, WorkflowResponseDto, WorkflowSearchDto, WorkflowShareResponseDto, @@ -83,6 +85,23 @@ export class WorkflowService extends BaseService { await this.workflowRepository.delete(id); } + async getLogs(auth: AuthDto, id: string, dto: WorkflowGetLogsDto): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowLogs, ids: [id] }); + const logs = await this.workflowRepository.getLogs(id, dto); + return logs.map((entry) => ({ + id: entry.id, + at: entry.createdAt, + result: entry.result, + triggerDataId: entry.triggerDataId ?? undefined, + lastStep: entry.step + ? { + index: entry.step.order, + method: `${entry.step.pluginId}#${entry.step.methodName}`, + } + : undefined, + })); + } + private async resolveAndValidateSteps(steps: T[], trigger: WorkflowTrigger) { const methods = await this.pluginRepository.getForValidation(); const results: Array = []; diff --git a/server/src/types.ts b/server/src/types.ts index 27995e841f..2eb996d14a 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,10 +1,9 @@ -import { WorkflowTrigger } from '@immich/plugin-sdk'; import { ShallowDehydrateObject } from 'kysely'; -import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; import { AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { @@ -30,16 +29,15 @@ import { SystemMetadataKey, TranscodeTarget, UserMetadataKey, - WorkflowType, } from 'src/enum'; import { Mocked } from 'vitest'; export type DeepPartial = T extends Date ? T - : T extends Record - ? { [K in keyof T]?: DeepPartial } - : T extends Array - ? DeepPartial[] + : T extends Array + ? DeepPartial[] + : T extends object + ? { [K in keyof T]?: DeepPartial } : T; export type RepositoryInterface = Pick; @@ -79,14 +77,6 @@ export interface DecodeToBufferOptions extends DecodeImageOptions { export type GenerateThumbnailOptions = Pick & DecodeToBufferOptions; export type GenerateThumbhashOptions = DecodeImageOptions; -export interface GenerateThumbnailsOptions { - colorspace: string; - preview?: ImageOptions; - processInvalidImages: boolean; - thumbhash?: boolean; - thumbnail?: ImageOptions; -} - export interface VideoStreamInfo { index: number; height: number; @@ -143,10 +133,6 @@ export interface ImageDimensions { height: number; } -export interface InputDimensions extends ImageDimensions { - inputPath: string; -} - export interface VideoInfo { format: VideoFormat; videoStreams: VideoStreamInfo[]; @@ -189,11 +175,6 @@ export interface BitrateDistribution { unit: string; } -export interface ImageBuffer { - data: Buffer; - info: RawImageInfo; -} - export interface VideoCodecSWConfig { getCommand( target: TranscodeTarget, @@ -234,6 +215,11 @@ export interface IDelayedJob extends IBaseJob { } export type JobSource = 'upload' | 'sidecar-write' | 'copy' | 'edit'; +export interface IPersonJob { + ownerId: string; + personGroupId: string; +} + export interface IEntityJob extends IBaseJob { id: string; source?: JobSource; @@ -260,18 +246,10 @@ export interface ILibraryBulkIdsJob { totalAssets: number; } -export interface IBulkEntityJob { - ids: string[]; -} - export interface IDeleteFilesJob extends IBaseJob { files: Array; } -export interface ISidecarWriteJob extends IEntityJob { - tags?: true; -} - export interface IDeferrableJob extends IEntityJob { deferred?: boolean; } @@ -307,12 +285,6 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } -export type IWorkflowJob = { - id: string; - trigger: WorkflowTrigger; - type: T; -}; - export interface IIntegrityJob { refreshOnly?: boolean; } @@ -385,7 +357,7 @@ export type JobItem = // Migration | { name: JobName.FileMigrationQueueAll; data?: IBaseJob } | { name: JobName.AssetFileMigration; data: IEntityJob } - | { name: JobName.PersonFileMigration; data: IEntityJob } + | { name: JobName.PersonFileMigration; data: IPersonJob } // Metadata Extraction | { name: JobName.AssetExtractMetadataQueueAll; data: IBaseJob } @@ -404,7 +376,7 @@ export type JobItem = | { name: JobName.AssetDetectFaces; data: IEntityJob } | { name: JobName.FacialRecognitionQueueAll; data: INightlyJob } | { name: JobName.FacialRecognition; data: IDeferrableJob } - | { name: JobName.PersonGenerateThumbnail; data: IEntityJob } + | { name: JobName.PersonGenerateThumbnail; data: IPersonJob } // Smart Search | { name: JobName.SmartSearchQueueAll; data: IBaseJob } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index ce8e614565..dc3c635ba7 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -126,6 +126,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return setUnion(isOwner, isPartner); } + case Permission.AssetFileDownload: { + return access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AssetView: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); @@ -164,6 +168,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetFileRead: + case Permission.AssetFileDelete: { + return await access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( @@ -299,8 +308,29 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.person.checkFaceOwnerAccess(auth.user.id, ids); } + case Permission.ClusterGroupRead: { + const isMember = await access.clusterGroup.checkOwnerAccess(auth.user.id, ids); + const isInvited = await access.clusterGroup.checkInviteAccess(auth.user.id, setDifference(ids, isMember)); + return setUnion(isMember, isInvited); + } + + case Permission.ClusterGroupLeave: + case Permission.ClusterGroupRequestCreate: { + return access.clusterGroup.checkOwnerAccess(auth.user.id, ids); + } + + case Permission.ClusterGroupRequestDelete: { + const isOwner = await access.clusterGroupRequest.checkOwnerAccess(auth.user.id, ids); + const isGroupMember = await access.clusterGroupRequest.checkGroupAccess(auth.user.id, ids); + return setUnion(isOwner, isGroupMember); + } + + case Permission.ClusterGroupRequestRead: { + return access.clusterGroupRequest.checkOwnerAccess(auth.user.id, ids); + } + case Permission.PartnerUpdate: { - return await access.partner.checkUpdateAccess(auth.user.id, ids); + return access.partner.checkUpdateAccess(auth.user.id, ids); } case Permission.SessionRead: @@ -324,7 +354,8 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe case Permission.WorkflowRead: case Permission.WorkflowUpdate: - case Permission.WorkflowDelete: { + case Permission.WorkflowDelete: + case Permission.WorkflowLogs: { return access.workflow.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index a6073471d1..38e5facb03 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -1,8 +1,7 @@ import AsyncLock from 'async-lock'; import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; -import { SystemConfig, defaults } from 'src/config'; -import { SystemConfigSchema } from 'src/dtos/system-config.dto'; +import { AdminConfigDto, SystemConfig, defaults } from 'src/dtos/config.dto'; import { DatabaseLock, SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -100,7 +99,7 @@ const buildConfig = async (repos: RepoDeps) => { } // validate with Zod schema - const result = SystemConfigSchema.safeParse(rawConfig); + const result = AdminConfigDto.schema.safeParse(rawConfig); if (!result.success) { const messages = ['Invalid system config: ']; for (const issue of result.error.issues) { diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0f4d8775b6..275b354c16 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -81,8 +81,6 @@ export const asUuid = (id: string | Expression) => sql`${id}::uu export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; -export const asVector = (embedding: number[]) => sql`${`[${embedding}]`}::vector`; - export const unnest = (array: string[]) => sql>`unnest(array[${sql.join(array)}]::text[])`; export const removeUndefinedKeys = (update: T, template: unknown) => { @@ -237,43 +235,68 @@ export function withFilePath(eb: ExpressionBuilder, type: AssetFile .where('asset_file.isEdited', '=', sql.lit(isEdited)); } -export function withFacesAndPeople( - eb: ExpressionBuilder, - withHidden?: boolean, - withDeletedFace?: boolean, -) { - return jsonArrayFrom( - eb - .selectFrom('asset_face') - .leftJoinLateral( - (eb) => - eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'), - (join) => join.onTrue(), - ) - .selectAll('asset_face') - .select((eb) => eb.table('person').$castTo>().as('person')) - .whereRef('asset_face.assetId', '=', 'asset.id') - .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) - .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), - ).as('faces'); +export type WithFacesAndPeopleOptions = { + /** whose version of the person to select */ + viewingUserId?: string; + withHidden?: boolean; + withDeletedFace?: boolean; +}; + +export function withFacesAndPeople({ viewingUserId, withHidden, withDeletedFace }: WithFacesAndPeopleOptions) { + return (eb: ExpressionBuilder) => + jsonArrayFrom( + eb + .selectFrom('asset_face') + .leftJoinLateral( + (eb) => + eb + .selectFrom('person') + .selectAll('person') + .whereRef('person.personGroupId', '=', 'asset_face.personGroupId') + .$if(!viewingUserId, (qb) => qb.whereRef('person.ownerId', '=', 'asset.ownerId')) + .$if(!!viewingUserId, (qb) => qb.where('person.ownerId', '=', viewingUserId!)) + .as('person'), + (join) => join.onTrue(), + ) + .selectAll('asset_face') + .select((eb) => eb.table('person').$castTo>().as('person')) + .whereRef('asset_face.assetId', '=', 'asset.id') + .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) + .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), + ).as('faces'); } -export function hasPeople(qb: SelectQueryBuilder, personIds: string[]) { +export function hasPeople(qb: SelectQueryBuilder, personGroupIds: string[]) { return qb.innerJoin( (eb) => eb .selectFrom('asset_face') .select('assetId') - .where('personId', '=', anyUuid(personIds!)) + .where('personGroupId', '=', anyUuid(personGroupIds!)) .where('deletedAt', 'is', null) .where('isVisible', 'is', true) .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .having((eb) => eb.fn.count('personGroupId').distinct(), '=', personGroupIds.length) .as('has_people'), (join) => join.onRef('has_people.assetId', '=', 'asset.id'), ); } +export function inSharedAlbum(eb: ExpressionBuilder, userId: string) { + return eb.exists( + eb + .selectFrom('album_asset') + .select(sql.lit(1).as('exists')) + .innerJoin('album', (join) => + join.onRef('album.id', '=', 'album_asset.albumId').on('album.deletedAt', 'is', null), + ) + .innerJoin('album_user', (join) => + join.onRef('album_user.albumId', '=', 'album.id').on('album_user.userId', '=', asUuid(userId)), + ) + .whereRef('album_asset.assetId', '=', 'asset.id'), + ); +} + export function inAlbums(qb: SelectQueryBuilder, albumIds: string[]) { return qb.innerJoin( (eb) => @@ -511,7 +534,9 @@ export function searchAssetBuilderLegacy(kysely: Kysely, options: AssetSearc ) .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) .$if(!!options.withExif, withExifInner) - .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) + .$if(!!(options.withFaces || options.withPeople), (qb) => + qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })), + ) .$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null)); } @@ -569,7 +594,7 @@ function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { } function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { - const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids)); + const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personGroupId', '=', anyUuid(ids)); return idsPredicates(eb, filter, { matchesAny: (ids) => eb.exists(matching(ids)), matchesAll: (ids) => @@ -577,7 +602,7 @@ function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { matching(ids) .select('asset_face.assetId') .groupBy('asset_face.assetId') - .having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length), + .having((eb) => eb.fn.count('asset_face.personGroupId').distinct(), '=', ids.length), ), }); } @@ -775,7 +800,9 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.userIds && options.userIds.length > 0, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)), ) - .$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople)) + .$if(!!(options.withFaces || options.withPeople), (qb) => + qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })), + ) .$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null)) .where((eb) => { const predicates = branchPredicates(eb, filter); diff --git a/server/src/utils/editor.spec.ts b/server/src/utils/editor.spec.ts index 17db0d9da3..ed013f9353 100644 --- a/server/src/utils/editor.spec.ts +++ b/server/src/utils/editor.spec.ts @@ -64,7 +64,7 @@ const createFace = (params: Partial = {}): AssetFace => ({ boundingBoxY2: 200, imageWidth: 1000, imageHeight: 1000, - personId: null, + personGroupId: null, sourceType: SourceType.MachineLearning, person: null, updatedAt: new Date(), diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 24d555f2fe..df3a0ce3e9 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -86,6 +86,6 @@ export const sendFile = async ( } }; -export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { - return new StreamableFile(stream, { type, length }); +export const asStreamableFile = ({ stream, type, disposition, length }: ImmichReadStream) => { + return new StreamableFile(stream, { type, disposition, length }); }; diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index aea821d6d5..49877904ec 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,5 +1,5 @@ import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { ConfigFFmpegDto } from 'src/dtos/config.dto'; import { ColorMatrix, ColorPrimaries, @@ -62,18 +62,18 @@ export const getCodecString = (codec: VideoCodec, width: number, height: number, export class BaseConfig implements VideoCodecSWConfig { readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; protected constructor( - protected config: SystemConfigFFmpegDto, + protected config: ConfigFFmpegDto, protected tune: VideoTuning = { strictGop: false, lowLatency: false }, ) {} - static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { + static create(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { if (config.accel === TranscodeHardwareAcceleration.Disabled) { return BaseConfig.getSWCodecConfig(config, tune); } return BaseConfig.getHWCodecConfig(config, interfaces, tune); } - private static getSWCodecConfig(config: SystemConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig { + private static getSWCodecConfig(config: ConfigFFmpegDto, tune?: VideoTuning): VideoCodecSWConfig { switch (config.targetVideoCodec) { case VideoCodec.H264: { return new H264Config(config, tune); @@ -93,7 +93,7 @@ export class BaseConfig implements VideoCodecSWConfig { } } - private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { + private static getHWCodecConfig(config: ConfigFFmpegDto, interfaces: VideoInterfaces, tune?: VideoTuning) { if (!SUPPORTED_HWA_CODECS[config.accel].includes(config.targetVideoCodec)) { throw new Error( `${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${SUPPORTED_HWA_CODECS[config.accel]}`, @@ -424,7 +424,7 @@ export class BaseHWConfig extends BaseConfig { protected device: string; constructor( - protected config: SystemConfigFFmpegDto, + protected config: ConfigFFmpegDto, protected interfaces: VideoInterfaces, tune?: VideoTuning, ) { @@ -471,7 +471,7 @@ export class BaseHWConfig extends BaseConfig { } export class ThumbnailConfig extends BaseConfig { - static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig { + static create(config: ConfigFFmpegDto): VideoCodecSWConfig { return new ThumbnailConfig(config); } @@ -712,10 +712,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig { : ['-cq:v', String(this.config.crf)]; } - getThreadOptions() { - return []; - } - getEncoderOptions(): string[] { const out = this.getOutputThreadOptions(); if (this.tune.strictGop) { diff --git a/server/src/utils/misc.spec.ts b/server/src/utils/misc.spec.ts index 3c45482938..e6b36d4a0d 100644 --- a/server/src/utils/misc.spec.ts +++ b/server/src/utils/misc.spec.ts @@ -1,4 +1,4 @@ -import { getKeysDeep, globToSqlPattern, unsetDeep } from 'src/utils/misc'; +import { getKeysDeep, globToPostgresRegex, unsetDeep } from 'src/utils/misc'; import { describe, expect, it } from 'vitest'; describe('getKeysDeep', () => { @@ -52,18 +52,26 @@ describe('unsetDeep', () => { }); }); -describe('globToSqlPattern', () => { - const testCases = [ - ['**/Raw/**', '%/Raw/%'], - ['**/abc/*.tif', '%/abc/%.tif'], - ['**/*.tif', '%/%.tif'], - ['**/*.jp?', '%/%.jp_'], - ['**/@eaDir/**', '%/@eaDir/%'], - ['**/._*', `%/._%`], - ['/absolute/path/**', `/absolute/path/%`], +const matches = (glob: string, value: string) => new RegExp(globToPostgresRegex(glob)).test(value); + +describe('globToPostgresRegex', () => { + const testCases: [string, string, boolean][] = [ + ['**/Raw/**', '/foo/Raw/bar.jpg', true], + ['**/Raw/**', '/foo/bar.jpg', false], + ['**/abc/*.tif', '/foo/abc/scan.tif', true], + ['**/abc/*.tif', '/foo/abc/sub/scan.tif', false], + ['**/*.tif', '/foo/bar.tif', true], + ['**/*.jp?', '/foo/bar.jpg', true], + ['**/@eaDir/**', '/foo/@eaDir/thumb.jpg', true], + ['**/._*', '/foo/._resource', true], + ['/absolute/path/**', '/absolute/path/photo.jpg', true], + ['/absolute/path/**', '/other/path/photo.jpg', false], + // a bare `*` must not cross a path separator, unlike SQL `LIKE`'s `%` + ['/path/*.*', '/path/photo.jpg', true], + ['/path/*.*', '/path/2020/photo.jpg', false], ]; - it.each(testCases)('should convert %s to %s', (input, expected) => { - expect(globToSqlPattern(input)).toEqual(expected); + it.each(testCases)('should match %s against %s as %s', (glob, value, expected) => { + expect(matches(glob, value)).toEqual(expected); }); }); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index a514007987..7727a8eb85 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -12,10 +12,9 @@ import { cleanupOpenApiDoc } from 'nestjs-zod'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; import picomatch from 'picomatch'; -import parse from 'picomatch/lib/parse'; -import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, JOBS_ASSET_PAGINATION_SIZE, endpointTags, serverVersion } from 'src/constants'; import { extraModels } from 'src/decorators'; +import { SystemConfig } from 'src/dtos/config.dto'; import { ApiCustomExtension, ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -338,37 +337,10 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) } }; -const convertTokenToSqlPattern = (token: parse.Token): string => { - switch (token.type) { - case 'slash': { - return '/'; - } - case 'text': { - return token.value; - } - case 'globstar': - case 'star': { - return '%'; - } - case 'underscore': { - return String.raw`\_`; - } - case 'qmark': { - return '_'; - } - case 'dot': { - return '.'; - } - default: { - return ''; - } - } -}; - -export const globToSqlPattern = (glob: string) => { - const tokens = picomatch.parse(glob).tokens; - return tokens.map((token) => convertTokenToSqlPattern(token)).join(''); -}; +// Compiles a glob to the equivalent Postgres regex (Postgres's Advanced Regular Expression +// dialect is a superset of what picomatch emits, so the two stay in sync with `picomatch.isMatch`, +// including which paths a lone `*` may cross vs `/`). +export const globToPostgresRegex = (glob: string) => picomatch.makeRe(glob).source; export function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); diff --git a/server/src/utils/profile-image.ts b/server/src/utils/profile-image.ts index ee94dd8986..ea2c0d3b6b 100644 --- a/server/src/utils/profile-image.ts +++ b/server/src/utils/profile-image.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; -import { SystemConfig } from 'src/config'; import { StorageCore } from 'src/cores/storage.core'; +import { SystemConfig } from 'src/dtos/config.dto'; import { StorageFolder } from 'src/enum'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { MediaRepository } from 'src/repositories/media.repository'; diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index c892239c80..a3a9d56c42 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -6,6 +6,7 @@ export const triggerMap: Record = { [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], // [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], + [WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1], }; export const getWorkflowTriggers = () => diff --git a/server/src/validation.spec.ts b/server/src/validation.spec.ts index 434ac89cee..7305c4d5cf 100644 --- a/server/src/validation.spec.ts +++ b/server/src/validation.spec.ts @@ -1,4 +1,4 @@ -import { IsNotSiblingOf } from 'src/validation'; +import { IsNotSiblingOf, toEmail } from 'src/validation'; import { describe, expect, it } from 'vitest'; import z from 'zod'; @@ -42,4 +42,32 @@ describe('Validation', () => { expect(result.success).toBe(true); }); }); + + describe('toEmail', () => { + it.each([ + 'test@immich.cloud', + 'test@immich', + 'first.last+tag@immich.cloud', + // unicode local parts and internationalized domain names + 'tëst@immich.cloud', + 'leoñ@immich.cloud', + 'test@яндекс.рф', + 'test@xn--d1acpjx3f.xn--p1ai', + '用户@例子.广告', + 'test@ท่องเที่ยว.ไทย', + ])('should accept %s', (email) => { + expect(toEmail.safeParse(email).success).toBe(true); + }); + + it.each(['immich', 'test@@immich.cloud', 'test user@immich.cloud', 'test@immich..cloud', 'test@-immich.cloud'])( + 'should reject %s', + (email) => { + expect(toEmail.safeParse(email).success).toBe(false); + }, + ); + + it('should convert the email to lower case', () => { + expect(toEmail.parse('tÉst@Immich.Cloud')).toBe('tést@immich.cloud'); + }); + }); }); diff --git a/server/src/validation.ts b/server/src/validation.ts index f8de2a68ff..467a87b676 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -119,14 +119,22 @@ const FilenameParamSchema = z.object({ export class FilenameParamDto extends createZodDto(FilenameParamSchema) {} +/** + * The HTML5 email regex, but with unicode support, so that international email addresses + * (unicode local parts and internationalized domain names) are accepted. + * @see {@link z.regexes.html5Email} + */ +const unicodeEmail = + /^[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; + /** * Unified email validation - * Converts email strings to lowercase and validates against HTML5 email regex + * Converts email strings to lowercase and validates against a unicode aware email regex * @docs https://zod.dev/api?id=email */ export const toEmail = z .email({ - pattern: z.regexes.html5Email, + pattern: unicodeEmail, error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`, }) .transform((val) => val.toLowerCase()); diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts index b2286cad54..c24e543afe 100644 --- a/server/test/factories/asset-face.factory.ts +++ b/server/test/factories/asset-face.factory.ts @@ -27,7 +27,7 @@ export class AssetFaceFactory { imageHeight: 500, imageWidth: 400, isVisible: true, - personId: null, + personGroupId: null, sourceType: SourceType.MachineLearning, updatedAt: newDate(), updateId: newUuidV7(), @@ -37,7 +37,7 @@ export class AssetFaceFactory { person(dto: PersonLike = {}, builder?: FactoryBuilder) { this.#person = build(PersonFactory.from(dto), builder); - this.value.personId = this.#person.build().id; + this.value.personGroupId = this.#person.build().personGroupId; return this; } diff --git a/server/test/factories/cluster-group.factory.ts b/server/test/factories/cluster-group.factory.ts new file mode 100644 index 0000000000..21a39bfd73 --- /dev/null +++ b/server/test/factories/cluster-group.factory.ts @@ -0,0 +1,27 @@ +import { Selectable } from 'kysely'; +import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table'; +import { ClusterGroupLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class ClusterGroupFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: ClusterGroupLike = {}) { + return ClusterGroupFactory.from(dto).build(); + } + + static from(dto: ClusterGroupLike = {}) { + return new ClusterGroupFactory({ + id: newUuid(), + name: null, + createdAt: newDate(), + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/person-group.factory.ts b/server/test/factories/person-group.factory.ts new file mode 100644 index 0000000000..436f777f67 --- /dev/null +++ b/server/test/factories/person-group.factory.ts @@ -0,0 +1,28 @@ +import { Selectable } from 'kysely'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; +import { PersonGroupLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class PersonGroupFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: PersonGroupLike = {}) { + return PersonGroupFactory.from(dto).build(); + } + + static from(dto: PersonGroupLike = {}) { + return new PersonGroupFactory({ + id: newUuid(), + clusterGroupId: newUuid(), + createdAt: newDate(), + createId: newUuidV7(), + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/person.factory.ts b/server/test/factories/person.factory.ts index 8e016e5398..84b0cf5d97 100644 --- a/server/test/factories/person.factory.ts +++ b/server/test/factories/person.factory.ts @@ -16,7 +16,7 @@ export class PersonFactory { color: null, createdAt: newDate(), faceAssetId: null, - id: newUuid(), + personGroupId: newUuid(), isFavorite: false, isHidden: false, name: 'person', diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 5c8c8ee2c2..a227deef8f 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -9,8 +9,10 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; +import { PersonGroupTable } from 'src/schema/tables/person-group.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -29,6 +31,8 @@ export type SharedLinkLike = Partial>; export type UserLike = Partial>; export type AssetFaceLike = Partial>; export type PersonLike = Partial>; +export type PersonGroupLike = Partial>; +export type ClusterGroupLike = Partial>; export type StackLike = Partial>; export type MemoryLike = Partial>; export type PartnerLike = Partial>; diff --git a/server/test/factories/user.factory.ts b/server/test/factories/user.factory.ts index 125ce91e86..df5366c9bf 100644 --- a/server/test/factories/user.factory.ts +++ b/server/test/factories/user.factory.ts @@ -17,6 +17,7 @@ export class UserFactory { static from(dto: UserLike = {}) { return new UserFactory({ id: newUuid(), + clusterGroupId: newUuid(), email: 'test@immich.cloud', password: '', pinCode: null, diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 88a2278898..7ac7c14978 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -28,19 +28,6 @@ export const authStub = { id: 'token-id', } as AuthSession, }), - user2: Object.freeze({ - user: { - id: 'user-2', - name: 'User 2', - email: 'user2@immich.cloud', - isAdmin: false, - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }, - session: { - id: 'token-id', - } as AuthSession, - }), adminWithElevatedPermission: Object.freeze({ user: authUser.admin, session: { diff --git a/server/test/fixtures/file.stub.ts b/server/test/fixtures/file.stub.ts index f63d2d10fb..e64b769496 100644 --- a/server/test/fixtures/file.stub.ts +++ b/server/test/fixtures/file.stub.ts @@ -6,13 +6,6 @@ export const fileStub = { originalName: 'asset_1.jpeg', size: 42, }), - livePhotoMotion: Object.freeze({ - uuid: 'live-photo-motion-asset', - originalPath: 'fake_path/asset_1.mp4', - checksum: Buffer.from('live photo file hash', 'utf8'), - originalName: 'asset_1.mp4', - size: 69, - }), photo: Object.freeze({ uuid: 'photo', originalPath: 'fake_path/photo1.jpeg', diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 77534fd6cb..4421a09801 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -451,28 +451,6 @@ export const videoInfoStub = { }, ], }), - videoStreamWithProfileLevel: Object.freeze({ - ...probeStubDefault, - videoStreams: [ - { - ...probeStubDefaultVideoStream[0], - codecName: 'h264', - profile: 100, - level: 40, - }, - ], - }), - audioStreamAAC: Object.freeze({ - ...probeStubDefault, - audioStreams: [ - { - index: 1, - codecName: 'aac', - profile: 2, - bitrate: 128_000, - }, - ], - }), }; interface SelectedStreams { diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index 355f2cc1a3..6ba6556921 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -1,4 +1,4 @@ -import { SystemConfig } from 'src/config'; +import { SystemConfig } from 'src/dtos/config.dto'; import { DeepPartial } from 'src/types'; export const systemConfigStub = { diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 21b49ab899..f6d3a42bde 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -6,6 +6,7 @@ export const userStub = { admin: { ...authStub.admin.user, status: UserStatus.Active, + clusterGroupId: 'cluster-group-id', profileChangedAt: new Date('2021-01-01'), name: 'admin_name', id: 'admin_id', @@ -24,6 +25,7 @@ export const userStub = { user1: { ...authStub.user1.user, status: UserStatus.Active, + clusterGroupId: 'cluster-group-id', profileChangedAt: new Date('2021-01-01'), name: 'immich_name', storageLabel: null, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 40ae78fe26..f2694f184f 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -1,6 +1,7 @@ import { Selectable, ShallowDehydrateObject } from 'kysely'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; +import { FaceSearchResult } from 'src/repositories/search.repository'; import { ActivityTable } from 'src/schema/tables/activity.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; @@ -12,6 +13,7 @@ import { MemoryFactory } from 'test/factories/memory.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { StackFactory } from 'test/factories/stack.factory'; import { UserFactory } from 'test/factories/user.factory'; +import { newUuid } from 'test/small.factory'; export const getForStorageTemplate = (asset: ReturnType) => { return { @@ -54,15 +56,27 @@ export const getAsDetectedFace = (face: ReturnType) = export const getForFacialRecognitionJob = ( face: ReturnType, - asset: Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null, + asset: + (Pick, 'ownerId' | 'visibility' | 'fileCreatedAt'> & { clusterGroupId?: string }) | null, ) => ({ ...face, asset: asset - ? { ownerId: asset.ownerId, visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt.toISOString() } + ? { + ownerId: asset.ownerId, + clusterGroupId: asset.clusterGroupId ?? newUuid(), + visibility: asset.visibility, + fileCreatedAt: asset.fileCreatedAt.toISOString(), + } : null, faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' }, }); +export const getForFaceSearch = (face: ReturnType, distance: number): FaceSearchResult => ({ + id: face.id, + personGroupId: face.personGroupId, + distance, +}); + export const getDehydrated = >(entity: T) => { const copiedEntity = structuredClone(entity); for (const [key, value] of Object.entries(copiedEntity)) { @@ -122,8 +136,12 @@ export const getForMemory = (memory: ReturnType) => ({ assets: memory.assets.map((asset) => getDehydrated(asset)), }); -export const getForMetadataExtraction = (asset: ReturnType) => ({ +export const getForMetadataExtraction = ( + asset: ReturnType, + { clusterGroupId }: { clusterGroupId?: string } = {}, +) => ({ id: asset.id, + clusterGroupId: clusterGroupId ?? newUuid(), checksum: asset.checksum, checksumAlgorithm: asset.checksumAlgorithm, fileCreatedAt: asset.fileCreatedAt, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index f0f14757b2..f6a2497e7e 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -4,9 +4,9 @@ import { createHash, randomBytes } from 'node:crypto'; import { Stats } from 'node:fs'; import { resolve } from 'node:path'; import { Writable } from 'node:stream'; -import { SystemConfig } from 'src/config'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; +import { SystemConfig } from 'src/dtos/config.dto'; import { AssetEditActionItem, AssetEditsCreateDto } from 'src/dtos/editing.dto'; import { AlbumUserRole, @@ -22,17 +22,22 @@ import { AccessRepository } from 'src/repositories/access.repository'; import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; +import { DuplicateRepository } from 'src/repositories/duplicate.repository'; import { EmailRepository } from 'src/repositories/email.repository'; import { EventRepository } from 'src/repositories/event.repository'; import { IntegrityRepository } from 'src/repositories/integrity.repository'; import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; import { MapRepository } from 'src/repositories/map.repository'; @@ -163,7 +168,8 @@ export class MediumTestContext = } async newUser(dto: Partial> = {}) { - const user = mediumFactory.userInsert(dto); + const clusterGroup = dto.clusterGroupId ? undefined : await this.get(ClusterGroupRepository).create(); + const user = mediumFactory.userInsert({ ...dto, clusterGroupId: dto.clusterGroupId ?? clusterGroup!.id }); const result = await this.get(UserRepository).create(user); return { user, result }; } @@ -248,6 +254,17 @@ export class MediumTestContext = return { albumUser: { albumId, userId, role }, result }; } + /** An album owned by one user, containing one asset, shared with a second user */ + async newSharedAlbum(dto: { role?: AlbumUserRole } = {}) { + const { user: owner } = await this.newUser(); + const { user: sharedWith } = await this.newUser(); + const { asset } = await this.newAsset({ ownerId: owner.id }); + const { album } = await this.newAlbum({ ownerId: owner.id }, [asset.id]); + await this.newAlbumUser({ albumId: album.id, userId: sharedWith.id, role: dto.role ?? AlbumUserRole.Editor }); + + return { album, asset, owner, sharedWith }; + } + async softDeleteAsset(assetId: string) { await this.database.updateTable('asset').set({ deletedAt: new Date() }).where('id', '=', assetId).execute(); } @@ -263,8 +280,14 @@ export class MediumTestContext = } async newPerson(dto: Partial> & { ownerId: string }) { - const person = mediumFactory.personInsert(dto); - const result = await this.get(PersonRepository).create(person); + const repository = this.get(PersonRepository); + let personGroupId = dto.personGroupId; + if (!personGroupId) { + const group = await repository.createGroup(dto.ownerId); + personGroupId = group.id; + } + const person = mediumFactory.personInsert({ ...dto, personGroupId }); + const result = await repository.create(person); return { person, result }; } @@ -442,11 +465,16 @@ const newRealRepository = (key: T, db: Kysely case AlbumRepository: case AlbumUserRepository: case ActivityRepository: + case ApiKeyRepository: case AssetRepository: case AssetEditRepository: + case AssetFileRepository: case AssetJobRepository: + case ClusterGroupRepository: + case DuplicateRepository: case IntegrityRepository: case MemoryRepository: + case LibraryRepository: case NotificationRepository: case OcrRepository: case PartnerRepository: @@ -513,6 +541,7 @@ const newMockRepository = (key: ClassConstructor) => { case AssetJobRepository: case ConfigRepository: case CryptoRepository: + case LibraryRepository: case MemoryRepository: case IntegrityRepository: case NotificationRepository: @@ -648,7 +677,7 @@ const assetFaceInsert = (assetFace: Partial & { assetId: string }) => id: assetFace.id ?? newUuid(), imageHeight: assetFace.imageHeight ?? 10, imageWidth: assetFace.imageWidth ?? 10, - personId: assetFace.personId ?? null, + personGroupId: assetFace.personGroupId ?? null, sourceType: assetFace.sourceType ?? SourceType.MachineLearning, isVisible: assetFace.isVisible ?? true, }; @@ -675,13 +704,12 @@ const assetJobStatusInsert = ( }; }; -const personInsert = (person: Partial> & { ownerId: string }) => { +const personInsert = (person: Partial> & { ownerId: string; personGroupId: string }) => { const defaults = { birthDate: person.birthDate || null, color: person.color || null, createdAt: person.createdAt || newDate(), faceAssetId: person.faceAssetId || null, - id: person.id || newUuid(), isFavorite: person.isFavorite || false, isHidden: person.isHidden || false, name: person.name || 'Test Name', @@ -715,7 +743,7 @@ const sessionInsert = ({ }; }; -const userInsert = (user: Partial> = {}) => { +const userInsert = (user: Partial> & { clusterGroupId: string }) => { const id = user.id || newUuid(); const defaults = { @@ -804,7 +832,7 @@ const loginDetails = () => { }; const loginResponse = (): LoginResponseDto => { - const user = userInsert({}); + const user = userInsert({ clusterGroupId: newUuid() }); return { accessToken: 'access-token', userId: user.id, diff --git a/server/test/medium/responses.ts b/server/test/medium/responses.ts index 9b75bc85ec..e8509e339b 100644 --- a/server/test/medium/responses.ts +++ b/server/test/medium/responses.ts @@ -1,27 +1,6 @@ import { expect } from 'vitest'; export const errorDto = { - unauthorized: { - message: 'Authentication required', - }, - forbidden: { - message: expect.any(String), - }, - missingPermission: (permission: string) => ({ - message: `Missing required permission: ${permission}`, - }), - wrongPassword: { - message: 'Wrong password', - }, - invalidToken: { - message: 'Invalid user token', - }, - invalidShareKey: { - message: 'Invalid share key', - }, - invalidSharePassword: { - message: 'Invalid password', - }, badRequest: (message: any = null) => ({ message: message ?? expect.anything(), }), @@ -29,10 +8,4 @@ export const errorDto = { message: 'Validation failed', errors: errors ? expect.arrayContaining(errors.map((e) => expect.objectContaining(e))) : expect.any(Array), }), - noPermission: { - message: expect.stringContaining('Not found or no'), - }, - incorrectLogin: { - message: 'Incorrect email or password', - }, }; diff --git a/server/test/medium/specs/repositories/asset.repository.spec.ts b/server/test/medium/specs/repositories/asset.repository.spec.ts index 2e449ae801..b623270b31 100644 --- a/server/test/medium/specs/repositories/asset.repository.spec.ts +++ b/server/test/medium/specs/repositories/asset.repository.spec.ts @@ -204,4 +204,11 @@ describe(AssetRepository.name, () => { ).resolves.toEqual({ lockedProperties: null }); }); }); + + describe('createAll', () => { + it('should return an empty array when given an empty input', async () => { + const { sut } = setup(); + await expect(sut.createAll([])).resolves.toStrictEqual([]); + }); + }); }); diff --git a/server/test/medium/specs/repositories/person.repository.spec.ts b/server/test/medium/specs/repositories/person.repository.spec.ts index 1ff9ade1d1..4d1604b601 100644 --- a/server/test/medium/specs/repositories/person.repository.spec.ts +++ b/server/test/medium/specs/repositories/person.repository.spec.ts @@ -23,6 +23,161 @@ beforeAll(async () => { }); describe(PersonRepository.name, () => { + describe('createAll', () => { + it('should create people in the groups they were given', async () => { + const { ctx, sut } = setup(); + const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()]; + + const [group1, group2] = await sut.createGroups([ + { clusterGroupId: user1.clusterGroupId }, + { clusterGroupId: user1.clusterGroupId }, + ]); + const group3 = await sut.createGroup(user2.id); + + const people = await sut.createAll([ + { ownerId: user1.id, name: 'Alice', personGroupId: group1.id }, + { ownerId: user1.id, name: 'Bob', personGroupId: group2.id }, + { ownerId: user2.id, name: 'Carol', personGroupId: group3.id }, + ]); + + expect(people.map(({ personGroupId }) => personGroupId)).toEqual([group1.id, group2.id, group3.id]); + + const groups = await ctx.database + .selectFrom('person') + .innerJoin('person_group', 'person_group.id', 'person.personGroupId') + .innerJoin('user', 'user.id', 'person.ownerId') + .select(['person.name', 'person_group.clusterGroupId', 'user.clusterGroupId as ownerClusterGroupId']) + .where( + 'person.personGroupId', + 'in', + people.map(({ personGroupId }) => personGroupId), + ) + .execute(); + + expect(groups).toHaveLength(3); + for (const group of groups) { + expect(group.clusterGroupId).toBe(group.ownerClusterGroupId); + } + }); + }); + + describe('createGroup', () => { + it('should create a group in the owner cluster group', async () => { + const { ctx, sut } = setup(); + const { user } = await ctx.newUser(); + + const group = await sut.createGroup(user.id); + + const owner = await ctx.database + .selectFrom('person_group') + .innerJoin('user', 'user.clusterGroupId', 'person_group.clusterGroupId') + .select('user.id') + .where('person_group.id', '=', group.id) + .executeTakeFirstOrThrow(); + + expect(owner.id).toBe(user.id); + }); + + it('should put people created with the same group into that group', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()]; + + const group = await sut.createGroup(user1.id); + const person1 = await sut.create({ ownerId: user1.id, name: 'Alice', personGroupId: group.id }); + const person2 = await sut.create({ ownerId: user2.id, name: 'Alice', personGroupId: group.id }); + + expect(person1.personGroupId).toBe(group.id); + expect(person2.personGroupId).toBe(group.id); + + const groups = await ctx.database.selectFrom('person_group').select('person_group.id').execute(); + expect(groups.map(({ id }) => id)).toEqual([group.id]); + }); + }); + + describe('getByGroupId', () => { + it('should not return a person owned by another user', async () => { + const { ctx, sut } = setup(); + const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()]; + const group = await sut.createGroup(user1.id); + + const person1 = await sut.create({ ownerId: user1.id, name: 'Alice', personGroupId: group.id }); + const person2 = await ctx.database + .insertInto('person') + .values({ ownerId: user2.id, name: 'Alice', personGroupId: person1.personGroupId }) + .returningAll() + .executeTakeFirstOrThrow(); + + await expect(sut.getByGroupId({ ownerId: user1.id, personGroupId: person1.personGroupId })).resolves.toEqual( + expect.objectContaining({ personGroupId: person1.personGroupId, ownerId: user1.id }), + ); + await expect(sut.getByGroupId({ ownerId: user2.id, personGroupId: person1.personGroupId })).resolves.toEqual( + expect.objectContaining({ personGroupId: person2.personGroupId, ownerId: user2.id }), + ); + }); + + it('should return nothing when the group belongs to another user', async () => { + const { ctx, sut } = setup(); + const [{ user: user1 }, { user: user2 }] = [await ctx.newUser(), await ctx.newUser()]; + const group = await sut.createGroup(user1.id); + + const person = await sut.create({ ownerId: user1.id, name: 'Alice', personGroupId: group.id }); + + await expect( + sut.getByGroupId({ ownerId: user2.id, personGroupId: person.personGroupId }), + ).resolves.toBeUndefined(); + }); + }); + + describe('deleteEmptyGroups', () => { + it('should delete groups that no longer have any people', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const { user } = await ctx.newUser(); + const [keptGroup, emptiedGroup] = await sut.createGroups([ + { clusterGroupId: user.clusterGroupId }, + { clusterGroupId: user.clusterGroupId }, + ]); + + const kept = await sut.create({ ownerId: user.id, name: 'Alice', personGroupId: keptGroup.id }); + const emptied = await sut.create({ ownerId: user.id, name: 'Bob', personGroupId: emptiedGroup.id }); + await ctx.database + .deleteFrom('person') + .where('person.ownerId', '=', emptied.ownerId) + .where('person.personGroupId', '=', emptied.personGroupId) + .execute(); + + await expect(sut.deleteEmptyGroups()).resolves.toBe(1); + + const groups = await ctx.database.selectFrom('person_group').select('person_group.id').execute(); + expect(groups.map(({ id }) => id)).toEqual([kept.personGroupId]); + }); + }); + + describe('deleteOrphanedClusterGroups', () => { + it('should delete cluster groups that no longer belong to a user, along with their people', async () => { + const { ctx, sut } = setup(await getKyselyDB()); + const [{ user: kept }, { user: removed }] = [await ctx.newUser(), await ctx.newUser()]; + const keptGroup = await sut.createGroup(kept.id); + const removedGroup = await sut.createGroup(removed.id); + + const keptPerson = await sut.create({ ownerId: kept.id, name: 'Alice', personGroupId: keptGroup.id }); + await sut.create({ ownerId: removed.id, name: 'Bob', personGroupId: removedGroup.id }); + const { clusterGroupId } = await ctx.database + .selectFrom('user') + .select('user.clusterGroupId') + .where('user.id', '=', kept.id) + .executeTakeFirstOrThrow(); + await ctx.database.deleteFrom('user').where('user.id', '=', removed.id).execute(); + + await expect(sut.deleteOrphanedClusterGroups()).resolves.toBe(1); + + const clusterGroups = await ctx.database.selectFrom('cluster_group').select('cluster_group.id').execute(); + expect(clusterGroups.map(({ id }) => id)).toEqual([clusterGroupId]); + + const groups = await ctx.database.selectFrom('person_group').select('person_group.id').execute(); + expect(groups.map(({ id }) => id)).toEqual([keptPerson.personGroupId]); + }); + }); + describe('getDataForThumbnailGenerationJob', () => { it('should not return the edited preview path', async () => { const { ctx, sut } = setup(); @@ -33,7 +188,7 @@ describe(PersonRepository.name, () => { const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, - personId: person.id, + personGroupId: person.personGroupId, boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, @@ -41,7 +196,12 @@ describe(PersonRepository.name, () => { }); // there's a circular dependency between assetFace and person, so we need to update the person after creating the assetFace - await ctx.database.updateTable('person').set({ faceAssetId: assetFace.id }).where('id', '=', person.id).execute(); + await ctx.database + .updateTable('person') + .set({ faceAssetId: assetFace.id }) + .where('ownerId', '=', person.ownerId) + .where('personGroupId', '=', person.personGroupId) + .execute(); await ctx.newAssetFile({ assetId: asset.id, @@ -56,7 +216,10 @@ describe(PersonRepository.name, () => { isEdited: false, }); - const result = await sut.getDataForThumbnailGenerationJob(person.id); + const result = await sut.getDataForThumbnailGenerationJob({ + ownerId: person.ownerId, + personGroupId: person.personGroupId, + }); expect(result).toEqual( expect.objectContaining({ diff --git a/server/test/medium/specs/services/activity.service.spec.ts b/server/test/medium/specs/services/activity.service.spec.ts new file mode 100644 index 0000000000..c568f05a26 --- /dev/null +++ b/server/test/medium/specs/services/activity.service.spec.ts @@ -0,0 +1,310 @@ +import { Kysely } from 'kysely'; +import { ReactionType } from 'src/dtos/activity.dto'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { ActivityRepository } from 'src/repositories/activity.repository'; +import { AlbumUserRepository } from 'src/repositories/album-user.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { ActivityService } from 'src/services/activity.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory, newUuid } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(ActivityService, { + database: db || defaultDatabase, + real: [AccessRepository, ActivityRepository, AlbumRepository, AlbumUserRepository, AssetRepository, UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(ActivityService.name, () => { + describe('getAll', () => { + it('should start off empty', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + + await expect(sut.getAll(factory.auth({ user: owner }), { albumId: album.id })).resolves.toEqual([]); + }); + + it('should filter by album id', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { album: other } = await ctx.newAlbum({ ownerId: owner.id }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + await sut.create(auth, { albumId: other.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([value]); + }); + + it('should filter by type=comment', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'comment', + }); + await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, type: ReactionType.COMMENT })).resolves.toEqual([value]); + }); + + it('should filter by type=like', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + await sut.create(auth, { albumId: album.id, type: ReactionType.COMMENT, comment: 'comment' }); + + await expect(sut.getAll(auth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual([value]); + }); + + it('should filter by userId', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, userId: newUuid() })).resolves.toEqual([]); + await expect(sut.getAll(auth, { albumId: album.id, userId: owner.id })).resolves.toEqual([value]); + }); + + it('should filter by assetId', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.getAll(auth, { albumId: album.id, assetId: asset.id })).resolves.toEqual([value]); + }); + }); + + describe('create', () => { + it('should add a comment to an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + + await expect( + sut.create(auth, { albumId: album.id, type: ReactionType.COMMENT, comment: 'This is my first comment' }), + ).resolves.toEqual({ + duplicate: false, + value: { + id: expect.any(String), + assetId: null, + createdAt: expect.any(Date), + type: ReactionType.COMMENT, + comment: 'This is my first comment', + user: expect.objectContaining({ id: owner.id }), + }, + }); + }); + + it('should add a like to an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + + await expect(sut.create(auth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual({ + duplicate: false, + value: { + id: expect.any(String), + assetId: null, + createdAt: expect.any(Date), + type: ReactionType.LIKE, + comment: null, + user: expect.objectContaining({ id: owner.id }), + }, + }); + }); + + it('should report a duplicate like on an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const ownerAuth = factory.auth({ user: owner }); + const { value } = await sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE })).resolves.toEqual({ + duplicate: true, + value, + }); + }); + + it('should not confuse an album like with an asset like', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const ownerAuth = factory.auth({ user: owner }); + const { value } = await sut.create(ownerAuth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + + const result = await sut.create(ownerAuth, { albumId: album.id, type: ReactionType.LIKE }); + + expect(result.duplicate).toBe(false); + expect(result.value.id).not.toEqual(value.id); + }); + + it('should add a comment to an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + + await expect( + sut.create(factory.auth({ user: owner }), { + albumId: album.id, + assetId: asset.id, + type: ReactionType.COMMENT, + comment: 'This is my first comment', + }), + ).resolves.toEqual( + expect.objectContaining({ + duplicate: false, + value: expect.objectContaining({ assetId: asset.id, comment: 'This is my first comment' }), + }), + ); + }); + + it('should add a like to an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + + await expect( + sut.create(factory.auth({ user: owner }), { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }), + ).resolves.toEqual( + expect.objectContaining({ + duplicate: false, + value: expect.objectContaining({ assetId: asset.id, type: ReactionType.LIKE, comment: null }), + }), + ); + }); + + it('should report a duplicate like on an asset', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + assetId: asset.id, + type: ReactionType.LIKE, + }); + + await expect( + sut.create(auth, { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }), + ).resolves.toEqual({ duplicate: true, value }); + }); + + it('should not let a user comment on an album they cannot access', async () => { + const { sut, ctx } = setup(); + const { album } = await ctx.newSharedAlbum(); + const { user: outsider } = await ctx.newUser(); + + await expect( + sut.create(factory.auth({ user: outsider }), { albumId: album.id, type: ReactionType.LIKE }), + ).rejects.toThrow('Not found or no activity.create access'); + }); + }); + + describe('delete', () => { + it('should remove a comment from an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should remove a like from an album', async () => { + const { sut, ctx } = setup(); + const { album, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const { value } = await sut.create(auth, { albumId: album.id, type: ReactionType.LIKE }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should let the album owner remove a comment by another user', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(sharedWithAuth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(auth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should not let a user remove a comment by another user', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(auth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(sharedWithAuth, value.id)).rejects.toThrow('Not found or no activity.delete access'); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([value]); + }); + + it('should let a non-owner remove their own comment', async () => { + const { sut, ctx } = setup(); + const { album, owner, sharedWith } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + const sharedWithAuth = factory.auth({ user: sharedWith }); + const { value } = await sut.create(sharedWithAuth, { + albumId: album.id, + type: ReactionType.COMMENT, + comment: 'This is a test comment', + }); + + await expect(sut.delete(sharedWithAuth, value.id)).resolves.toBeUndefined(); + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + + it('should drop activities when the asset is removed from the album', async () => { + const { sut, ctx } = setup(); + const { album, asset, owner } = await ctx.newSharedAlbum(); + const auth = factory.auth({ user: owner }); + await sut.create(auth, { albumId: album.id, assetId: asset.id, type: ReactionType.LIKE }); + + await ctx.database + .deleteFrom('album_asset') + .where('albumId', '=', album.id) + .where('assetId', '=', asset.id) + .execute(); + + await expect(sut.getAll(auth, { albumId: album.id })).resolves.toEqual([]); + }); + }); +}); diff --git a/server/test/medium/specs/services/album.service.spec.ts b/server/test/medium/specs/services/album.service.spec.ts index bc73007593..9670f4c5ca 100644 --- a/server/test/medium/specs/services/album.service.spec.ts +++ b/server/test/medium/specs/services/album.service.spec.ts @@ -1,10 +1,13 @@ import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; import { AlbumService } from 'src/services/album.service'; import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -12,7 +15,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(AlbumService, { database: db || defaultDatabase, - real: [AlbumRepository, UserRepository], + real: [AccessRepository, AlbumRepository, AssetRepository, UserRepository], mock: [LoggingRepository], }); }; @@ -22,16 +25,35 @@ beforeAll(async () => { }); describe(AlbumService.name, () => { + describe('removeAssets', () => { + it('should not remove assets from an album of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }, [asset.id]); + + await expect(sut.removeAssets(factory.auth({ user: otherUser }), album.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no albumAsset.delete access', + ); + await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id); + }); + }); + describe('database triggers', () => { it('should cascade delete an album when the owner is deleted', async () => { const { ctx } = setup(); const { user } = await ctx.newUser(); - await ctx.newAlbum({ ownerId: user.id }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); await ctx.get(UserRepository).delete({ id: user.id }, true); - await expect(ctx.database.selectFrom('album').selectAll().execute()).resolves.toEqual([]); - await expect(ctx.database.selectFrom('album_user').selectAll().execute()).resolves.toEqual([]); + await expect(ctx.database.selectFrom('album').selectAll().where('id', '=', album.id).execute()).resolves.toEqual( + [], + ); + await expect( + ctx.database.selectFrom('album_user').selectAll().where('albumId', '=', album.id).execute(), + ).resolves.toEqual([]); }); }); }); diff --git a/server/test/medium/specs/services/api-key.service.spec.ts b/server/test/medium/specs/services/api-key.service.spec.ts new file mode 100644 index 0000000000..32fbf7b9d9 --- /dev/null +++ b/server/test/medium/specs/services/api-key.service.spec.ts @@ -0,0 +1,106 @@ +import { Kysely } from 'kysely'; +import { Permission } from 'src/enum'; +import { ApiKeyRepository } from 'src/repositories/api-key.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { DB } from 'src/schema'; +import { ApiKeyService } from 'src/services/api-key.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(ApiKeyService, { + database: db || defaultDatabase, + real: [ApiKeyRepository, CryptoRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(ApiKeyService.name, () => { + describe('getById', () => { + it('should not return an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.getById(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + }); + }); + + describe('update', () => { + it('should not update an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.update(factory.auth({ user: otherUser }), apiKey.id, { name: 'new name' })).rejects.toThrow( + 'API Key not found', + ); + }); + }); + + describe('delete', () => { + it('should not delete an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.delete(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + await expect(sut.getById(factory.auth({ user }), apiKey.id)).resolves.toEqual( + expect.objectContaining({ id: apiKey.id }), + ); + }); + }); + + describe('rotate', () => { + it('should not rotate an api key of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + + await expect(sut.rotate(factory.auth({ user: otherUser }), apiKey.id)).rejects.toThrow('API Key not found'); + }); + + it('should not rotate a key with permissions the caller does not have', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { apiKey } = await sut.create(factory.auth({ user }), { permissions: [Permission.All] }); + const auth = factory.auth({ user, apiKey: { permissions: [Permission.ApiKeyRotate] } }); + + await expect(sut.rotate(auth, apiKey.id)).rejects.toThrow( + 'Cannot rotate an API Key with permissions you do not have', + ); + }); + + it('should replace the secret of an api key', async () => { + const { sut, ctx } = setup(); + const apiKeyRepo = ctx.get(ApiKeyRepository); + const crypto = ctx.get(CryptoRepository); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { apiKey, secret } = await sut.create(auth, { permissions: [Permission.All] }); + + const rotated = await sut.rotate(auth, apiKey.id); + + expect(rotated.secret).not.toEqual(secret); + expect(rotated.apiKey).toEqual( + expect.objectContaining({ id: apiKey.id, name: apiKey.name, permissions: [Permission.All] }), + ); + await expect(apiKeyRepo.getKey(crypto.hashSha256(secret))).resolves.toBeUndefined(); + await expect(apiKeyRepo.getKey(crypto.hashSha256(rotated.secret))).resolves.toEqual( + expect.objectContaining({ id: apiKey.id }), + ); + }); + }); +}); diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 2569b29353..cb6e1d744d 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -45,6 +45,19 @@ beforeAll(async () => { }); describe(AssetService.name, () => { + describe('get', () => { + it('should not return an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect(sut.get(factory.auth({ user: otherUser }), asset.id)).rejects.toThrow( + 'Not found or no asset.read access', + ); + }); + }); + describe('getStatistics', () => { it('should return stats as numbers, not strings', async () => { const { sut, ctx } = setup(); @@ -334,6 +347,17 @@ describe(AssetService.name, () => { }); describe('update', () => { + it('should not update an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await expect(sut.update(factory.auth({ user: otherUser }), asset.id, {})).rejects.toThrow( + 'Not found or no asset.update access', + ); + }); + it('should automatically lock lockable columns', async () => { const { sut, ctx } = setup(); ctx.getMock(JobRepository).queue.mockResolvedValue(); diff --git a/server/test/medium/specs/services/auth.service.spec.ts b/server/test/medium/specs/services/auth.service.spec.ts index 5e5c880955..af969bae9a 100644 --- a/server/test/medium/specs/services/auth.service.spec.ts +++ b/server/test/medium/specs/services/auth.service.spec.ts @@ -3,6 +3,7 @@ import { hash } from 'bcrypt'; import { Kysely } from 'kysely'; import { AuthType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; @@ -26,6 +27,7 @@ const setup = (db?: Kysely) => { database: db || defaultDatabase, real: [ AccessRepository, + ClusterGroupRepository, ConfigRepository, CryptoRepository, DatabaseRepository, diff --git a/server/test/medium/specs/services/cluster-group.service.spec.ts b/server/test/medium/specs/services/cluster-group.service.spec.ts new file mode 100644 index 0000000000..2192e89e60 --- /dev/null +++ b/server/test/medium/specs/services/cluster-group.service.spec.ts @@ -0,0 +1,475 @@ +import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { ClusterGroupService } from 'src/services/cluster-group.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const ctx = newMediumService(ClusterGroupService, { + database: db || defaultDatabase, + real: [AccessRepository, ClusterGroupRepository, PersonRepository, UserRepository], + mock: [LoggingRepository, EventRepository], + }); + + ctx.ctx.getMock(EventRepository).emit.mockResolvedValue(); + + return ctx; +}; + +const getClusterGroupId = async (ctx: ReturnType['ctx'], userId: string) => { + const { clusterGroupId } = await ctx.database + .selectFrom('user') + .select('user.clusterGroupId') + .where('user.id', '=', userId) + .executeTakeFirstOrThrow(); + + return clusterGroupId; +}; + +const getPeople = (ctx: ReturnType['ctx'], ownerId: string) => + ctx.database.selectFrom('person').selectAll('person').where('person.ownerId', '=', ownerId).execute(); + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(ClusterGroupService.name, () => { + describe('createRequest', () => { + it('should create a request for another user', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const auth = factory.auth({ user: owner }); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id }); + + expect(request).toEqual( + expect.objectContaining({ clusterGroupId, userId: invitee.id, createdAt: expect.any(Date) }), + ); + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('ClusterGroupRequest', { + clusterGroupId, + userId: invitee.id, + senderName: owner.name, + }); + }); + + it('should reject a cluster group the user is not a member of', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const auth = factory.auth({ user: owner }); + const otherClusterGroupId = await getClusterGroupId(ctx, other.id); + + await expect(sut.createRequest(auth, otherClusterGroupId, { userId: other.id })).rejects.toThrow( + 'Not found or no clusterGroupRequest.create access', + ); + }); + + it('should return the existing request when it was already created', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const auth = factory.auth({ user: owner }); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const created = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id }); + expect(created.duplicate).toBe(false); + + const again = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id }); + expect(again.duplicate).toBe(true); + expect(again.value).toEqual(created.value); + + await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([created.value]); + }); + + it('should reject an unknown user', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const auth = factory.auth({ user: owner }); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + await expect(sut.createRequest(auth, clusterGroupId, { userId: factory.uuid() })).rejects.toThrow( + 'User not found', + ); + }); + }); + + describe('getRequests', () => { + it('should only return the requests for the current user', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: invitee.id, + }); + await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { userId: other.id }); + + await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([request]); + }); + }); + + describe('getRequestsForGroup', () => { + it('should return the requests sent by the cluster group', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const auth = factory.auth({ user: owner }); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: invitee.id }); + + await expect(sut.getRequestsForGroup(auth, clusterGroupId)).resolves.toEqual([request]); + await expect(sut.getRequestsForGroup(factory.auth({ user: other }), clusterGroupId)).rejects.toThrow( + 'Not found or no clusterGroup.read access', + ); + }); + }); + + describe('getUsers', () => { + it('should return the members of the cluster group', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: member } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const auth = factory.auth({ user: owner }); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { value: request } = await sut.createRequest(auth, clusterGroupId, { userId: member.id }); + await sut.acceptRequest(factory.auth({ user: member }), request.id); + + const users = await sut.getUsers(auth, clusterGroupId); + expect(users.map(({ id }) => id)).toEqual(expect.arrayContaining([owner.id, member.id])); + expect(users.map(({ id }) => id)).not.toContain(other.id); + + await expect(sut.getUsers(factory.auth({ user: other }), clusterGroupId)).rejects.toThrow( + 'Not found or no clusterGroup.read access', + ); + }); + + it('should let a user with a pending request see the members', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const auth = factory.auth({ user: owner }); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + await expect(sut.getUsers(factory.auth({ user: invitee }), clusterGroupId)).rejects.toThrow( + 'Not found or no clusterGroup.read access', + ); + + await sut.createRequest(auth, clusterGroupId, { userId: invitee.id }); + + const users = await sut.getUsers(factory.auth({ user: invitee }), clusterGroupId); + expect(users.map(({ id }) => id)).toContain(owner.id); + }); + }); + + describe('acceptRequest', () => { + it('should move the user into the cluster group and delete the request', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: invitee.id, + }); + await sut.acceptRequest(factory.auth({ user: invitee }), request.id); + + await expect(getClusterGroupId(ctx, invitee.id)).resolves.toBe(clusterGroupId); + await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]); + }); + + it('should not accept a request belonging to someone else', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + const otherClusterGroupId = await getClusterGroupId(ctx, other.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: invitee.id, + }); + + await expect(sut.acceptRequest(factory.auth({ user: other }), request.id)).rejects.toThrow( + 'Not found or no clusterGroupRequest.read access', + ); + await expect(getClusterGroupId(ctx, other.id)).resolves.toBe(otherClusterGroupId); + }); + }); + + describe('leave', () => { + it('should move the user into a new cluster group', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + const auth = factory.auth({ user }); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: user.id, + }); + await sut.acceptRequest(auth, request.id); + + await sut.leave(auth, clusterGroupId); + + const newClusterGroupId = await getClusterGroupId(ctx, user.id); + expect(newClusterGroupId).not.toBe(clusterGroupId); + await expect( + ctx.database + .selectFrom('cluster_group') + .select('cluster_group.id') + .where('cluster_group.id', '=', newClusterGroupId) + .executeTakeFirst(), + ).resolves.toBeDefined(); + }); + + it('should reject a cluster group the user is not a member of', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const auth = factory.auth({ user }); + const otherClusterGroupId = await getClusterGroupId(ctx, other.id); + + await expect(sut.leave(auth, otherClusterGroupId)).rejects.toThrow('Not found or no clusterGroup.leave access'); + }); + + it('should not let the last member leave', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const clusterGroupId = await getClusterGroupId(ctx, user.id); + + await expect(sut.leave(auth, clusterGroupId)).rejects.toThrow( + 'Cannot leave a cluster group without any other members', + ); + await expect(getClusterGroupId(ctx, user.id)).resolves.toBe(clusterGroupId); + }); + }); + + describe('joining a cluster group', () => { + it('should take the groups of the user along', async () => { + const { sut, ctx } = setup(await getKyselyDB()); + const personRepo = ctx.get(PersonRepository); + const { user: owner } = await ctx.newUser(); + const { user } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { person } = await ctx.newPerson({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: user.id, + }); + await sut.acceptRequest(factory.auth({ user }), request.id); + + // the group keeps its id, it only changes cluster group + await expect(personRepo.getByGroupId(person)).resolves.toEqual( + expect.objectContaining({ personGroupId: person.personGroupId }), + ); + await expect( + ctx.database + .selectFrom('person_group') + .select('person_group.clusterGroupId') + .where('person_group.id', '=', person.personGroupId) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ clusterGroupId }); + await expect( + ctx.database + .selectFrom('asset_face') + .select('asset_face.personGroupId') + .where('asset_face.id', '=', assetFace.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ personGroupId: person.personGroupId }); + await expect(getClusterGroupId(ctx, user.id)).resolves.toBe(clusterGroupId); + }); + }); + + describe('leaving a shared cluster group', () => { + it('should recreate the shared groups and take the rest of its own along', async () => { + const { sut, ctx } = setup(await getKyselyDB()); + const personRepo = ctx.get(PersonRepository); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, user1.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: user1 }), clusterGroupId, { + userId: user2.id, + }); + await sut.acceptRequest(factory.auth({ user: user2 }), request.id); + + // a group both of them have a person in + const { person: shared1 } = await ctx.newPerson({ ownerId: user1.id }); + await ctx.newPerson({ ownerId: user2.id, personGroupId: shared1.personGroupId }); + // a group only the leaving user has a person in + const { person: only2 } = await ctx.newPerson({ ownerId: user2.id }); + // a group only the remaining user has a person in + const { person: only1 } = await ctx.newPerson({ ownerId: user1.id }); + + const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id }); + const { assetFace: sharedFace2 } = await ctx.newAssetFace({ + assetId: asset2.id, + personGroupId: shared1.personGroupId, + }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user1.id }); + const { assetFace: sharedFace1 } = await ctx.newAssetFace({ + assetId: asset1.id, + personGroupId: shared1.personGroupId, + }); + + await sut.leave(factory.auth({ user: user2 }), clusterGroupId); + + const newClusterGroupId = await getClusterGroupId(ctx, user2.id); + expect(newClusterGroupId).not.toBe(clusterGroupId); + + // the shared group is recreated for the leaving user, the one only they had comes along as it is + const leaverPeople = await getPeople(ctx, user2.id); + const movedShared = leaverPeople.find(({ personGroupId }) => personGroupId !== only2.personGroupId); + expect(movedShared).toBeDefined(); + expect(movedShared!.personGroupId).not.toBe(shared1.personGroupId); + await expect(personRepo.getByGroupId(only2)).resolves.toBeDefined(); + + // the remaining user is untouched + await expect(personRepo.getByGroupId(shared1)).resolves.toBeDefined(); + await expect(personRepo.getByGroupId(only1)).resolves.toBeDefined(); + + const groups = await ctx.database + .selectFrom('person_group') + .select(['person_group.id', 'person_group.clusterGroupId']) + .execute(); + expect(groups).toEqual( + expect.arrayContaining([ + { id: shared1.personGroupId, clusterGroupId }, + { id: only1.personGroupId, clusterGroupId }, + { id: only2.personGroupId, clusterGroupId: newClusterGroupId }, + { id: movedShared!.personGroupId, clusterGroupId: newClusterGroupId }, + ]), + ); + + // only the faces on the assets of the leaving user follow the recreated group + const faces = await ctx.database + .selectFrom('asset_face') + .select(['asset_face.id', 'asset_face.personGroupId']) + .where('asset_face.id', 'in', [sharedFace1.id, sharedFace2.id]) + .execute(); + expect(faces).toEqual( + expect.arrayContaining([ + { id: sharedFace1.id, personGroupId: shared1.personGroupId }, + { id: sharedFace2.id, personGroupId: movedShared!.personGroupId }, + ]), + ); + }); + + it('should give each shared group its own new group when a user leaves the group', async () => { + const { sut, ctx } = setup(await getKyselyDB()); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, user1.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: user1 }), clusterGroupId, { + userId: user2.id, + }); + await sut.acceptRequest(factory.auth({ user: user2 }), request.id); + + // two groups both users have a person in + const { person: sharedA } = await ctx.newPerson({ ownerId: user1.id }); + const { person: sharedB } = await ctx.newPerson({ ownerId: user1.id }); + await ctx.newPerson({ ownerId: user2.id, personGroupId: sharedA.personGroupId, name: 'Alice' }); + await ctx.newPerson({ ownerId: user2.id, personGroupId: sharedB.personGroupId, name: 'Bob' }); + + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { assetFace: faceA } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedA.personGroupId }); + const { assetFace: faceB } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: sharedB.personGroupId }); + + await sut.leave(factory.auth({ user: user2 }), clusterGroupId); + + const moved = await getPeople(ctx, user2.id); + const movedGroupIds = moved.map(({ personGroupId }) => personGroupId); + expect(movedGroupIds).toHaveLength(2); + expect(movedGroupIds).not.toContain(sharedA.personGroupId); + expect(movedGroupIds).not.toContain(sharedB.personGroupId); + expect(new Set(movedGroupIds).size).toBe(2); + + // the group id changes on the way out, so the name is what identifies each person + const movedA = moved.find(({ name }) => name === 'Alice'); + const movedB = moved.find(({ name }) => name === 'Bob'); + + const faces = await ctx.database + .selectFrom('asset_face') + .select(['asset_face.id', 'asset_face.personGroupId']) + .where('asset_face.id', 'in', [faceA.id, faceB.id]) + .execute(); + expect(faces).toEqual( + expect.arrayContaining([ + { id: faceA.id, personGroupId: movedA!.personGroupId }, + { id: faceB.id, personGroupId: movedB!.personGroupId }, + ]), + ); + }); + }); + + describe('deleteRequest', () => { + it('should let the user it was created for decline it', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + const inviteeClusterGroupId = await getClusterGroupId(ctx, invitee.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: invitee.id, + }); + await sut.deleteRequest(factory.auth({ user: invitee }), request.id); + + await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]); + await expect(getClusterGroupId(ctx, invitee.id)).resolves.toBe(inviteeClusterGroupId); + }); + + it('should let the cluster group it was created by revoke it', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: invitee.id, + }); + await sut.deleteRequest(factory.auth({ user: owner }), request.id); + + await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([]); + }); + + it('should not let an unrelated user delete it', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: invitee } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const clusterGroupId = await getClusterGroupId(ctx, owner.id); + + const { value: request } = await sut.createRequest(factory.auth({ user: owner }), clusterGroupId, { + userId: invitee.id, + }); + + await expect(sut.deleteRequest(factory.auth({ user: other }), request.id)).rejects.toThrow( + 'Not found or no clusterGroupRequest.delete access', + ); + await expect(sut.getRequests(factory.auth({ user: invitee }))).resolves.toEqual([request]); + }); + }); +}); diff --git a/server/test/medium/specs/services/duplicate.service.spec.ts b/server/test/medium/specs/services/duplicate.service.spec.ts new file mode 100644 index 0000000000..6afae277cb --- /dev/null +++ b/server/test/medium/specs/services/duplicate.service.spec.ts @@ -0,0 +1,622 @@ +import { Kysely } from 'kysely'; +import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; +import { AssetStatus, AssetVisibility } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { DuplicateRepository } from 'src/repositories/duplicate.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; +import { TagRepository } from 'src/repositories/tag.repository'; +import { DB } from 'src/schema'; +import { DuplicateService } from 'src/services/duplicate.service'; +import { clearConfigCache } from 'src/utils/config'; +import { MediumTestContext, newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + const { sut, ctx } = newMediumService(DuplicateService, { + database: db || defaultDatabase, + real: [ + AccessRepository, + AlbumRepository, + AssetRepository, + ConfigRepository, + DuplicateRepository, + SystemMetadataRepository, + TagRepository, + ], + mock: [EventRepository, JobRepository, LoggingRepository], + }); + + ctx.getMock(EventRepository).emit.mockResolvedValue(); + ctx.getMock(JobRepository).queueAll.mockResolvedValue(); + + return { sut, ctx }; +}; + +const newDuplicateAsset = async ( + ctx: MediumTestContext, + { + ownerId, + duplicateId, + ...dto + }: { ownerId: string; duplicateId?: string; isFavorite?: boolean; visibility?: AssetVisibility }, + exif: { rating?: number; description?: string; latitude?: number; longitude?: number; fileSizeInByte?: number } = {}, +) => { + const { asset } = await ctx.newAsset({ ownerId, duplicateId, ...dto }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: 1000, ...exif }); + return asset; +}; + +const newTag = async (ctx: MediumTestContext, userId: string, value: string) => { + return ctx.get(TagRepository).create({ userId, value }); +}; + +const expectSuccess = (results: BulkIdResponseDto[], duplicateId: string) => { + expect(results).toEqual([{ id: duplicateId, success: true }]); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +beforeEach(() => { + clearConfigCache(); +}); + +describe(DuplicateService.name, () => { + describe('getDuplicates', () => { + it('should return an empty list when the user has no duplicates', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + }); + + it('should return a duplicate group with a suggested keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const small = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 1000 }); + const large = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { fileSizeInByte: 2000 }); + + const auth = factory.auth({ user: { id: user.id } }); + const duplicates = await sut.getDuplicates(auth); + + expect(duplicates).toHaveLength(1); + expect(duplicates[0].duplicateId).toBe(duplicateId); + expect(duplicates[0].assets.map(({ id }) => id).sort()).toEqual([small.id, large.id].sort()); + // largest file size wins + expect(duplicates[0].suggestedKeepAssetIds).toEqual([large.id]); + }); + + it('should not return duplicates owned by someone else', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + + const auth = factory.auth({ user: { id: other.id } }); + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + }); + + it('should clear the duplicateId of a singleton group', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect(sut.getDuplicates(auth)).resolves.toEqual([]); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ duplicateId: null }); + }); + }); + + describe('resolve', () => { + it('should throw when the duplicate group does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user: { id: user.id } }); + + await expect( + sut.resolve(auth, { groups: [{ duplicateId: factory.uuid(), keepAssetIds: [], trashAssetIds: [] }] }), + ).rejects.toThrow('Not found or no duplicate.delete access'); + }); + + it('should throw when the duplicate group belongs to another user', async () => { + const { sut, ctx } = setup(); + const { user: owner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: owner.id, duplicateId }); + + const auth = factory.auth({ user: { id: other.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }), + ).rejects.toThrow('Not found or no duplicate.delete access'); + }); + + it('should clear the duplicateId of the keeper and trash the rest', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + const assetRepo = ctx.get(AssetRepository); + await expect(assetRepo.getById(keeper.id)).resolves.toMatchObject({ + duplicateId: null, + deletedAt: null, + }); + await expect(assetRepo.getById(trashed.id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Trashed, + }); + }); + + it('should emit AssetTrashAll for the trashed assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + duplicateId, + ); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetTrashAll', { + assetIds: [trashed.id], + userId: user.id, + }); + }); + + it('should fail when an asset is in both keepAssetIds and trashAssetIds', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset1.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'An asset cannot be in both keepAssetIds and trashAssetIds', + }, + ]); + }); + + it('should fail when the group is only partially covered', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [asset2.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + }); + + it('should ignore ids that are not members of the group', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const outsider = await newDuplicateAsset(ctx, { ownerId: user.id }); + + // the outsider is filtered out, which leaves asset2 uncovered + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [asset1.id], trashAssetIds: [outsider.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + + await expect(ctx.get(AssetRepository).getById(outsider.id)).resolves.toMatchObject({ deletedAt: null }); + }); + + it('should trash every asset when no keepers are given', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const asset1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const asset2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [], trashAssetIds: [asset1.id, asset2.id] }], + }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + const assetRepo = ctx.get(AssetRepository); + for (const { id } of [asset1, asset2]) { + await expect(assetRepo.getById(id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Trashed, + }); + } + }); + + it('should fail without permission to delete an asset owned by someone else', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const mine = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const theirs = await newDuplicateAsset(ctx, { ownerId: other.id, duplicateId }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [mine.id], trashAssetIds: [theirs.id] }] }), + ).resolves.toEqual([ + { + id: duplicateId, + success: false, + error: BulkIdErrorReason.NO_PERMISSION, + errorMessage: 'No permission to delete assets', + }, + ]); + + await expect(ctx.get(AssetRepository).getById(theirs.id)).resolves.toMatchObject({ deletedAt: null }); + }); + + it('should resolve several groups in one request', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId1 = factory.uuid(); + const duplicateId2 = factory.uuid(); + + const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 }); + const trashed1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId1 }); + const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 }); + const trashed2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: duplicateId2 }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [ + { duplicateId: duplicateId1, keepAssetIds: [keeper1.id], trashAssetIds: [trashed1.id] }, + { duplicateId: duplicateId2, keepAssetIds: [keeper2.id], trashAssetIds: [trashed2.id] }, + ], + }), + ).resolves.toEqual([ + { id: duplicateId1, success: true }, + { id: duplicateId2, success: true }, + ]); + + const assetRepo = ctx.get(AssetRepository); + await expect(assetRepo.getById(trashed1.id)).resolves.toMatchObject({ status: AssetStatus.Trashed }); + await expect(assetRepo.getById(trashed2.id)).resolves.toMatchObject({ status: AssetStatus.Trashed }); + }); + + it('should report per-group failures without abandoning the valid groups', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const goodId = factory.uuid(); + const badId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: goodId }); + // the bad group exists (so access passes) but is left partially covered + const badKeeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId }); + await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId: badId }); + + const auth = factory.auth({ user: { id: user.id } }); + const results = await sut.resolve(auth, { + groups: [ + { duplicateId: goodId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }, + { duplicateId: badId, keepAssetIds: [badKeeper.id], trashAssetIds: [] }, + ], + }); + + expect(results).toEqual([ + { id: goodId, success: true }, + { + id: badId, + success: false, + error: BulkIdErrorReason.VALIDATION, + errorMessage: 'Every asset must be in either keepAssetIds or trashAssetIds', + }, + ]); + + // the good group still went through + await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({ + status: AssetStatus.Trashed, + }); + }); + + describe('metadata merging', () => { + it('should merge isFavorite into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + await expect(ctx.get(AssetRepository).getById(keeper.id)).resolves.toMatchObject({ + isFavorite: true, + duplicateId: null, + }); + }); + + it('should merge the most restrictive visibility into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeperAsset = await newDuplicateAsset(ctx, { + ownerId: user.id, + duplicateId, + visibility: AssetVisibility.Timeline, + }); + const trashedAsset = await newDuplicateAsset(ctx, { + ownerId: user.id, + duplicateId, + visibility: AssetVisibility.Archive, + }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeperAsset.id], trashAssetIds: [trashedAsset.id] }], + }), + duplicateId, + ); + + await expect(ctx.get(AssetRepository).getById(keeperAsset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Archive, + duplicateId: null, + }); + }); + + it('should merge the highest rating into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 1 }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { rating: 5 }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ rating: 5 }); + }); + + it('should merge distinct description lines into the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { description: 'from keeper' }); + const trashed = await newDuplicateAsset( + ctx, + { ownerId: user.id, duplicateId }, + { description: 'from trashed' }, + ); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo?.description?.split('\n').sort()).toEqual(['from keeper', 'from trashed']); + }); + + it('should merge a location that both assets agree on', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset( + ctx, + { ownerId: user.id, duplicateId }, + { latitude: 40.7128, longitude: -74.006 }, + ); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ latitude: 40.7128, longitude: -74.006 }); + }); + + it('should not merge a location the assets disagree on', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 1, longitude: 1 }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }, { latitude: 2, longitude: 2 }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { exifInfo: true }); + expect(result?.exifInfo).toMatchObject({ latitude: 1, longitude: 1 }); + }); + + it('should add the keeper to every album the group belonged to', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const { album: album1 } = await ctx.newAlbum({ ownerId: user.id }, [keeper.id]); + const { album: album2 } = await ctx.newAlbum({ ownerId: user.id }, [trashed.id]); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const albumRepo = ctx.get(AlbumRepository); + await expect(albumRepo.getAssetIds(album1.id, [keeper.id])).resolves.toEqual(new Set([keeper.id])); + await expect(albumRepo.getAssetIds(album2.id, [keeper.id])).resolves.toEqual(new Set([keeper.id])); + }); + + it('should merge the tags of the group onto the keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const tag1 = await newTag(ctx, user.id, 'tag1'); + const tag2 = await newTag(ctx, user.id, 'tag2'); + await ctx.newTagAsset({ tagIds: [tag1.id], assetIds: [keeper.id] }); + await ctx.newTagAsset({ tagIds: [tag2.id], assetIds: [trashed.id] }); + + const auth = factory.auth({ user: { id: user.id } }); + expectSuccess( + await sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }], + }), + duplicateId, + ); + + const result = await ctx.get(AssetRepository).getById(keeper.id, { tags: true }); + expect(result?.tags?.map(({ id }) => id).sort()).toEqual([tag1.id, tag2.id].sort()); + }); + + it('should not merge metadata when there is more than one keeper', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper1 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: false }); + const keeper2 = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId, isFavorite: true }); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { + groups: [{ duplicateId, keepAssetIds: [keeper1.id, keeper2.id], trashAssetIds: [] }], + }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + await expect(ctx.get(AssetRepository).getById(keeper1.id)).resolves.toMatchObject({ + isFavorite: false, + duplicateId: null, + }); + }); + }); + + describe('when trash is disabled', () => { + it('should delete instead of trash', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const duplicateId = factory.uuid(); + + const keeper = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + const trashed = await newDuplicateAsset(ctx, { ownerId: user.id, duplicateId }); + + const config = await ctx.getConfig(); + await ctx.updateConfig({ ...config, trash: { ...config.trash, enabled: false } }); + clearConfigCache(); + + const auth = factory.auth({ user: { id: user.id } }); + await expect( + sut.resolve(auth, { groups: [{ duplicateId, keepAssetIds: [keeper.id], trashAssetIds: [trashed.id] }] }), + ).resolves.toEqual([{ id: duplicateId, success: true }]); + + await expect(ctx.get(AssetRepository).getById(trashed.id)).resolves.toMatchObject({ + duplicateId: null, + status: AssetStatus.Deleted, + }); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AssetDeleteAll', { + assetIds: [trashed.id], + userId: user.id, + }); + }); + }); + }); +}); diff --git a/server/test/medium/specs/services/library.service.spec.ts b/server/test/medium/specs/services/library.service.spec.ts new file mode 100644 index 0000000000..4284e47fee --- /dev/null +++ b/server/test/medium/specs/services/library.service.spec.ts @@ -0,0 +1,877 @@ +import { Kysely } from 'kysely'; +import { existsSync } from 'node:fs'; +import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; +import { AssetStatus, JobName, JobStatus } from 'src/enum'; +import { AssetJobRepository } from 'src/repositories/asset-job.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { JobRepository } from 'src/repositories/job.repository'; +import { LibraryRepository } from 'src/repositories/library.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { DB } from 'src/schema'; +import { LibraryService } from 'src/services/library.service'; +import { MediumTestContext, testAssetsDir } from 'test/medium.factory'; +import { newUuid } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +// `validateImportPath` checks candidate paths against the media location +StorageCore.setMediaLocation('/photos'); + +let defaultDatabase: Kysely; + +const fileModifiedAt = new Date(1_700_000_000_000); + +const createFile = async (filePath: string, modifiedAt: Date = fileModifiedAt) => { + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, 'test'); + await utimes(filePath, modifiedAt, modifiedAt); + return filePath; +}; + +const copyTestAsset = async (source: string, filePath: string, modifiedAt: Date = fileModifiedAt) => { + await mkdir(dirname(filePath), { recursive: true }); + await copyFile(join(testAssetsDir, source), filePath); + await utimes(filePath, modifiedAt, modifiedAt); + return filePath; +}; + +class LibraryTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(LibraryService, { + database, + real: [AssetRepository, AssetJobRepository, CryptoRepository, LibraryRepository, StorageRepository], + mock: [EventRepository, JobRepository, LoggingRepository], + }); + + const jobs = this.getMock(JobRepository); + jobs.queue.mockResolvedValue(); + jobs.queueAll.mockResolvedValue(); + + this.getMock(EventRepository).emit.mockResolvedValue(); + } + + async createLibrary(options: { importPaths?: string[]; exclusionPatterns?: string[] } = {}) { + const { user } = await this.newUser(); + return this.get(LibraryRepository).create({ + ownerId: user.id, + name: 'Medium test library', + importPaths: options.importPaths ?? [], + exclusionPatterns: options.exclusionPatterns ?? [], + }); + } + + /** Runs a full library scan, manually routing jobs to their handlers */ + async scan(libraryId: string) { + const jobs = this.getMock(JobRepository); + + jobs.queue.mockClear(); + await this.sut.handleQueueSyncFiles({ id: libraryId }); + for (const [job] of jobs.queue.mock.calls) { + if (job.name === JobName.LibrarySyncFiles) { + await this.sut.handleSyncFiles(job.data); + } + } + + jobs.queue.mockClear(); + await this.sut.handleQueueSyncAssets({ id: libraryId }); + for (const [job] of jobs.queue.mock.calls) { + if (job.name === JobName.LibrarySyncAssets) { + await this.sut.handleSyncAssets(job.data); + } + } + } + + /** The paths a library scan left visible, i.e. neither offline nor trashed */ + async getAssetPaths(libraryId: string) { + const assets = await this.database + .selectFrom('asset') + .select('originalPath') + .where('libraryId', '=', libraryId) + .where('deletedAt', 'is', null) + .execute(); + + return assets.map(({ originalPath }) => originalPath).sort(); + } +} + +const setup = (db?: Kysely) => { + const ctx = new LibraryTestContext(db || defaultDatabase); + return { sut: ctx.sut, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(LibraryService.name, () => { + let tempDir: string; + let importRoot: string; + let importPath: string; + let excludedPath: string; + let outsidePath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'immich-library-')); + importRoot = join(tempDir, 'libraries/offline'); + importPath = join(importRoot, 'in-path'); + excludedPath = join(importRoot, 'excluded'); + outsidePath = join(tempDir, 'libraries/outside'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe('create', () => { + it('should create an external library with defaults', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect(sut.create({ ownerId: user.id })).resolves.toEqual( + expect.objectContaining({ + ownerId: user.id, + name: 'New External Library', + refreshedAt: null, + assetCount: 0, + importPaths: [], + exclusionPatterns: expect.any(Array), + }), + ); + }); + + it('should create an external library with options', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect( + sut.create({ + ownerId: user.id, + name: 'My Awesome Library', + importPaths: [importPath], + exclusionPatterns: ['**/Raw/**'], + }), + ).resolves.toEqual( + expect.objectContaining({ + name: 'My Awesome Library', + importPaths: [importPath], + exclusionPatterns: ['**/Raw/**'], + }), + ); + }); + }); + + describe('get', () => { + it('should get a library by id', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.get(library.id)).resolves.toEqual( + expect.objectContaining({ + id: library.id, + ownerId: library.ownerId, + refreshedAt: null, + assetCount: 0, + importPaths: [importPath], + exclusionPatterns: [], + }), + ); + }); + + it('should throw an error when the library does not exist', async () => { + const { sut } = setup(); + + await expect(sut.get(newUuid())).rejects.toThrow('Library not found'); + }); + }); + + describe('update', () => { + it('should change the library name', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.update(library.id, { name: 'New Library Name' })).resolves.toEqual( + expect.objectContaining({ name: 'New Library Name' }), + ); + }); + + it('should change the import paths', async () => { + const { sut, ctx } = setup(); + await mkdir(importPath, { recursive: true }); + const library = await ctx.createLibrary(); + + await expect(sut.update(library.id, { importPaths: [importPath] })).resolves.toEqual( + expect.objectContaining({ importPaths: [importPath] }), + ); + }); + + it('should reject an import path that does not exist', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary(); + + await expect(sut.update(library.id, { importPaths: [join(tempDir, 'missing')] })).rejects.toThrow( + 'Invalid import path: Path does not exist (ENOENT)', + ); + }); + + it('should change the exclusion patterns', async () => { + const { sut, ctx } = setup(); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await expect(sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] })).resolves.toEqual( + expect.objectContaining({ exclusionPatterns: ['**/Raw/**'] }), + ); + }); + }); + + describe('validate', () => { + it('should pass with no import paths', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: [] })).resolves.toEqual({ importPaths: [] }); + }); + + it('should fail if the path does not exist', async () => { + const { sut } = setup(); + const missingPath = join(tempDir, 'does/not/exist'); + + await expect(sut.validate(newUuid(), { importPaths: [missingPath] })).resolves.toEqual({ + importPaths: [{ importPath: missingPath, isValid: false, message: 'Path does not exist (ENOENT)' }], + }); + }); + + it('should fail if the path is not absolute', async () => { + const { sut } = setup(); + + await expect(sut.validate(newUuid(), { importPaths: ['relative/path'] })).resolves.toEqual({ + importPaths: [ + { + importPath: 'relative/path', + isValid: false, + message: `Import path must be absolute, try ${resolve('relative/path')}`, + }, + ], + }); + }); + + it('should fail if the path is a file', async () => { + const { sut } = setup(); + const filePath = await createFile(join(importPath, 'assetA.png')); + + await expect(sut.validate(newUuid(), { importPaths: [filePath] })).resolves.toEqual({ + importPaths: [{ importPath: filePath, isValid: false, message: 'Not a directory' }], + }); + }); + }); + + describe('handleDeleteLibrary', () => { + it('should delete an empty library', async () => { + const { sut, ctx } = setup(); + const libraryRepo = ctx.get(LibraryRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await sut.delete(library.id); + + // the library is hidden right away, but the row survives until the job runs + await expect(libraryRepo.get(library.id)).resolves.toBeUndefined(); + await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id })); + expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({ + name: JobName.LibraryDelete, + data: { id: library.id }, + }); + + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); + + await expect(libraryRepo.get(library.id, true)).resolves.toBeUndefined(); + }); + + it('should delete a library with assets without deleting the files', async () => { + const { sut, ctx } = setup(); + const libraryRepo = ctx.get(LibraryRepository); + const jobs = ctx.getMock(JobRepository); + const assetA = await createFile(join(importPath, 'assetA.png')); + const assetB = await createFile(join(importPath, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + + await sut.delete(library.id); + await expect(libraryRepo.get(library.id)).resolves.toBeUndefined(); + + jobs.queueAll.mockClear(); + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); + + // the assets are trashed and queued for removal, so the library row stays until they are gone + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + await expect(libraryRepo.get(library.id, true)).resolves.toEqual(expect.objectContaining({ id: library.id })); + // deleteOnDisk is what keeps the files of an external library, so assert it explicitly + expect(jobs.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } }, + { name: JobName.AssetDelete, data: { id: expect.any(String), deleteOnDisk: false } }, + ]); + + // the asset delete jobs are only queued here, so this just proves the handler itself unlinks nothing + expect(existsSync(assetA)).toBe(true); + expect(existsSync(assetB)).toBe(true); + }); + }); + + describe('queueScan', () => { + it('should import a new asset', async () => { + const { ctx } = setup(); + + const assetPath = await createFile(join(importPath, 'assetA.png')); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetPath]); + }); + + it('should scan multiple import paths', async () => { + const { ctx } = setup(); + + const directoryA = join(importRoot, 'directoryA'); + const directoryB = join(importRoot, 'directoryB'); + const assetA = await createFile(join(directoryA, 'assetA.png')); + const assetB = await createFile(join(directoryB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [directoryA, directoryB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with commas', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder, a'); + const folderB = join(importRoot, 'folder, b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // https://github.com/immich-app/immich/issues/10699 + it('should scan multiple import paths with braces', async () => { + const { ctx } = setup(); + + const folderA = join(importRoot, 'folder{ a'); + const folderB = join(importRoot, 'folder} b'); + const assetA = await createFile(join(folderA, 'assetA.png')); + const assetB = await createFile(join(folderB, 'assetB.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + }); + + // We never got backslashes to work + const annoyingChars = [ + "'", + '"', + '`', + '*', + '{', + '}', + ',', + '(', + ')', + '[', + ']', + '?', + '!', + '@', + '#', + '$', + '%', + '^', + '&', + '=', + '+', + '~', + '|', + '<', + '>', + ';', + ':', + '/', + ]; + + it.each(annoyingChars)('should scan multiple import paths with %s', async (char) => { + const { ctx } = setup(); + + const folderA = join(importRoot, `folder${char}1`); + const folderB = join(importRoot, `folder${char}2`); + const asset1 = await createFile(join(folderA, 'asset1.png')); + const asset2 = await createFile(join(folderB, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [folderA, folderB] }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + }); + + it('should not import assets covered by an exclusion pattern', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + }); + + it('should not import assets covered by multiple exclusion patterns', async () => { + const { ctx } = setup(); + + await createFile(join(importRoot, 'directoryA/assetA.png')); + await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'], + }); + + await ctx.scan(library.id); + + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + it('should offline assets covered by a new exclusion pattern', async () => { + const { sut, ctx } = setup(); + + const assetA = await createFile(join(importRoot, 'directoryA/assetA.png')); + const assetB = await createFile(join(importRoot, 'directoryB/assetB.png')); + const library = await ctx.createLibrary({ importPaths: [importRoot] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetA, assetB].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([assetB]); + + await sut.update(library.id, { exclusionPatterns: ['**/directoryA/**', '**/directoryB/**'] }); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([]); + }); + + // https://github.com/immich-app/immich/issues/17121 + it('should respect exclusion patterns when using multiple import paths', async () => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + // a second import path that never exists on disk, as in the original report + const missingPath = join(importRoot, 'exclusion2'); + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, 'Raw/asset2.png')); + const library = await ctx.createLibrary({ importPaths: [`${inPath}/`, `${missingPath}/`] }); + + // scanning twice must be idempotent + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: ['**/Raw/**'] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + + const annoyingExclusionPatterns = ['@', '#', '$', '%', '^', '&', '=']; + + it.each(annoyingExclusionPatterns)('should support exclusion patterns with %s', async (char) => { + const { sut, ctx } = setup(); + + const inPath = join(importRoot, 'exclusion'); + const excludedFolder = `${char}folder`; + const asset1 = await createFile(join(inPath, 'asset1.png')); + const asset2 = await createFile(join(inPath, excludedFolder, 'asset2.png')); + const library = await ctx.createLibrary({ importPaths: [inPath] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1, asset2].sort()); + + await sut.update(library.id, { exclusionPatterns: [`**/${excludedFolder}/**`] }); + + await ctx.scan(library.id); + await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]); + }); + }); + + describe('handleQueueSyncAssets', () => { + it('should set an asset offline if its file is not in any import path', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(outsidePath, 'offline.png')), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set an asset offline if its file is covered by an exclusion pattern', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(excludedPath, 'offline.png')), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + }); + + describe('handleSyncAssets', () => { + it('should set an asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + // the file is intentionally never created on disk + originalPath: join(importPath, 'offline.png'), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should not set an asset offline if file exists in import path and is not excluded', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(importPath, 'online.png')), + fileModifiedAt, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeNull(); + }); + + it('should set an offline asset to online if its file exists in an import path and is not excluded', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(importPath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeNull(); + }); + + it('should not set an offline asset to online if its file exists in an import path but is excluded', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ + importPaths: [importRoot], + exclusionPatterns: ['**/excluded/**'], + }); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(excludedPath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should keep an offline asset offline if it is outside import paths', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(outsidePath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set a trashed asset offline if its file is missing', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + // the file is intentionally never created on disk + originalPath: join(importPath, 'offline.png'), + isExternal: true, + isOffline: false, + deletedAt: new Date(), + status: AssetStatus.Trashed, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: true })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should set a trashed offline asset to online but keep it in trash', async () => { + const { sut, ctx } = setup(); + const assetRepo = ctx.get(AssetRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: await createFile(join(importPath, 'offline.png')), + isExternal: true, + isOffline: true, + deletedAt: new Date(), + status: AssetStatus.Trashed, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + const updated = await assetRepo.getById(asset.id); + expect(updated).toEqual(expect.objectContaining({ isOffline: false })); + expect(updated?.deletedAt).toBeInstanceOf(Date); + }); + + it('should queue sidecar checks for assets whose file changed', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: rawPath, + // the file on disk has a newer modified time + fileModifiedAt: new Date(fileModifiedAt.valueOf() - 1000), + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).toHaveBeenCalledWith([ + { + name: JobName.SidecarCheck, + data: { id: asset.id, source: 'upload' }, + }, + ]); + }); + + it('should not queue sidecar checks for unchanged assets', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); + + const { asset } = await ctx.newAsset({ + ownerId: library.ownerId, + libraryId: library.id, + originalPath: rawPath, + fileModifiedAt, + isExternal: true, + isOffline: false, + status: AssetStatus.Active, + }); + + await expect( + sut.handleSyncAssets({ + libraryId: library.id, + importPaths: library.importPaths, + exclusionPatterns: library.exclusionPatterns, + assetIds: [asset.id], + progressCounter: 1, + totalAssets: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).not.toHaveBeenCalled(); + }); + }); + + describe('handleSyncFiles', () => { + it('should queue sidecar checks for newly imported assets', async () => { + const { sut, ctx } = setup(); + const jobs = ctx.getMock(JobRepository); + const library = await ctx.createLibrary({ importPaths: [importPath] }); + const rawPath = await copyTestAsset('formats/raw/Nikon/D80/glarus.nef', join(importPath, 'glarus.nef')); + + await expect( + sut.handleSyncFiles({ + libraryId: library.id, + paths: [rawPath], + progressCounter: 1, + }), + ).resolves.toBe(JobStatus.Success); + + expect(jobs.queueAll).toHaveBeenCalledWith([ + expect.objectContaining({ + name: JobName.SidecarCheck, + data: expect.objectContaining({ id: expect.any(String) }), + }), + ]); + }); + }); +}); diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index b3a3da6010..5a9217ea60 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -34,7 +34,70 @@ const setup = (db?: Kysely) => { }); }; +/** A memory owned by one user, plus another user's auth to attempt access with */ +const newMemoryOfAnotherUser = async (ctx: ReturnType['ctx']) => { + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { memory } = await ctx.newMemory({ ownerId: user.id }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + return { memory, asset, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) }; +}; + describe(MemoryService.name, () => { + describe('get', () => { + it('should not return a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.get(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.read access'); + }); + }); + + describe('update', () => { + it('should not update a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.update(otherAuth, memory.id, { isSaved: true })).rejects.toThrow( + 'Not found or no memory.update access', + ); + }); + }); + + describe('remove', () => { + it('should not remove a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, auth, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.remove(otherAuth, memory.id)).rejects.toThrow('Not found or no memory.delete access'); + await expect(sut.get(auth, memory.id)).resolves.toEqual(expect.objectContaining({ id: memory.id })); + }); + }); + + describe('addAssets', () => { + it('should not add assets to a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + + await expect(sut.addAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no memory.read access', + ); + }); + }); + + describe('removeAssets', () => { + it('should not remove assets from a memory of another user', async () => { + const { sut, ctx } = setup(); + const { memory, asset, otherAuth } = await newMemoryOfAnotherUser(ctx); + await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id }); + + await expect(sut.removeAssets(otherAuth, memory.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no memory.update access', + ); + }); + }); + beforeEach(async () => { defaultDatabase = await getKyselyDB(); }); diff --git a/server/test/medium/specs/services/metadata.service.spec.ts b/server/test/medium/specs/services/metadata.service.spec.ts index 37603520f7..c3f111e5aa 100644 --- a/server/test/medium/specs/services/metadata.service.spec.ts +++ b/server/test/medium/specs/services/metadata.service.spec.ts @@ -151,6 +151,59 @@ describe(MetadataService.name, () => { // note that this date is technically wrong. it does not throw though and should get the user's attention either way. ).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') }); }); + + it('should ignore IFD1 thumbnail orientation when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ 'IFD1:Orientation#': 6 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('orientation') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ orientation: null }); + }); + + it('should ignore IFD1 thumbnail dimensions when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ 'IFD1:ImageWidth#': 160, 'IFD1:ImageHeight#': 120 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual( + expect.objectContaining({ width: 1, height: 1 }), + ); + }); + + it('should keep IFD0 orientation when extracting metadata', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { filePath } = await createTestFile({ 'IFD0:Orientation#': 6 }); + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, description: '' }); + + await sut.handleMetadataExtraction({ id: asset.id }); + + await expect( + ctx.database + .selectFrom('asset_exif') + .select('orientation') + .where('assetId', '=', asset.id) + .executeTakeFirstOrThrow(), + ).resolves.toEqual({ orientation: '6' }); + }); }); it('should handle float lens models (#30492)', async () => { diff --git a/server/test/medium/specs/services/ocr.service.spec.ts b/server/test/medium/specs/services/ocr.service.spec.ts index 5f69aea850..efcc7aa136 100644 --- a/server/test/medium/specs/services/ocr.service.spec.ts +++ b/server/test/medium/specs/services/ocr.service.spec.ts @@ -56,6 +56,7 @@ describe(OcrService.name, () => { boxScore: 0.99, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Test OCR', textScore: 0.95, isVisible: true, @@ -107,6 +108,7 @@ describe(OcrService.name, () => { boxScore: 0.7, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'One', textScore: 0.9, isVisible: true, @@ -124,6 +126,7 @@ describe(OcrService.name, () => { boxScore: 0.67, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Two', textScore: 0.89, isVisible: true, @@ -141,6 +144,7 @@ describe(OcrService.name, () => { boxScore: 0.65, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Three', textScore: 0.88, isVisible: true, @@ -158,6 +162,7 @@ describe(OcrService.name, () => { boxScore: 0.62, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Four', textScore: 0.87, isVisible: true, @@ -175,6 +180,7 @@ describe(OcrService.name, () => { boxScore: 0.6, id: expect.any(String), updateId: expect.any(String), + updatedAt: expect.any(Date), text: 'Five', textScore: 0.86, isVisible: true, diff --git a/server/test/medium/specs/services/partner.service.spec.ts b/server/test/medium/specs/services/partner.service.spec.ts new file mode 100644 index 0000000000..addbd2bdb6 --- /dev/null +++ b/server/test/medium/specs/services/partner.service.spec.ts @@ -0,0 +1,124 @@ +import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PartnerDirection, PartnerRepository } from 'src/repositories/partner.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { PartnerService } from 'src/services/partner.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory, newUuid } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(PartnerService, { + database: db || defaultDatabase, + real: [AccessRepository, PartnerRepository, UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(PartnerService.name, () => { + describe('create', () => { + it('should share with a new partner', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + + await expect(sut.create(factory.auth({ user }), { sharedWithId: partner.id })).resolves.toEqual( + expect.objectContaining({ id: partner.id }), + ); + }); + + it('should not share with a partner that is already shared with', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: user.id, sharedWithId: partner.id }); + + await expect(sut.create(factory.auth({ user }), { sharedWithId: partner.id })).rejects.toThrow( + 'Partner already exists', + ); + }); + + it('should not share with an unknown user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect(sut.create(factory.auth({ user }), { sharedWithId: newUuid() })).rejects.toThrow('Invalid user'); + }); + }); + + describe('search', () => { + it('should return partners shared by the user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: user.id, sharedWithId: partner.id }); + + await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedBy })).resolves.toEqual([ + expect.objectContaining({ id: partner.id }), + ]); + }); + + it('should return partners that share with the user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id }); + + await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedWith })).resolves.toEqual([ + expect.objectContaining({ id: partner.id }), + ]); + }); + }); + + describe('update', () => { + it('should update a partner', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id }); + + await expect(sut.update(factory.auth({ user }), partner.id, { inTimeline: false })).resolves.toEqual( + expect.objectContaining({ id: partner.id, inTimeline: false }), + ); + }); + + it('should not update a partner that did not share with the user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + const { user: other } = await ctx.newUser(); + await ctx.newPartner({ sharedById: partner.id, sharedWithId: other.id }); + + await expect(sut.update(factory.auth({ user }), partner.id, { inTimeline: false })).rejects.toThrow( + 'Not found or no partner.update access', + ); + }); + }); + + describe('remove', () => { + it('should remove a partner', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: user.id, sharedWithId: partner.id }); + + await expect(sut.remove(factory.auth({ user }), partner.id)).resolves.toBeUndefined(); + await expect(sut.search(factory.auth({ user }), { direction: PartnerDirection.SharedBy })).resolves.toEqual([]); + }); + + it('should throw when the partner does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + await expect(sut.remove(factory.auth({ user }), newUuid())).rejects.toThrow('Partner not found'); + }); + }); +}); diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index 39805580f6..579012d04c 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -1,4 +1,5 @@ import { Kysely } from 'kysely'; +import { DateTime } from 'luxon'; import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; import { AssetFaceCreateDto } from 'src/dtos/person.dto'; import { AccessRepository } from 'src/repositories/access.repository'; @@ -47,9 +48,11 @@ describe(PersonService.name, () => { const auth = factory.auth({ user }); storageMock.unlink.mockResolvedValue(); - await expect(personRepo.getById(person.id)).resolves.toEqual(expect.objectContaining({ id: person.id })); - await expect(sut.delete(auth, person.id)).resolves.toBeUndefined(); - await expect(personRepo.getById(person.id)).resolves.toBeUndefined(); + await expect(personRepo.getByGroupId(person)).resolves.toEqual( + expect.objectContaining({ personGroupId: person.personGroupId }), + ); + await expect(sut.delete(auth, person.personGroupId)).resolves.toBeUndefined(); + await expect(personRepo.getByGroupId(person)).resolves.toBeUndefined(); expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath); }); @@ -73,9 +76,11 @@ describe(PersonService.name, () => { const auth = factory.auth({ user }); storageMock.unlink.mockResolvedValue(); - await expect(sut.deleteAll(auth, { ids: [person1.id, person2.id] })).resolves.toBeUndefined(); - await expect(personRepo.getById(person1.id)).resolves.toBeUndefined(); - await expect(personRepo.getById(person2.id)).resolves.toBeUndefined(); + await expect( + sut.deleteAll(auth, { ids: [person1.personGroupId, person2.personGroupId] }), + ).resolves.toBeUndefined(); + await expect(personRepo.getByGroupId(person1)).resolves.toBeUndefined(); + await expect(personRepo.getByGroupId(person2)).resolves.toBeUndefined(); expect(storageMock.unlink).toHaveBeenCalledTimes(2); expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath); @@ -83,6 +88,100 @@ describe(PersonService.name, () => { }); }); + describe('mergePerson', () => { + it('should merge people of multiple users', async () => { + const { sut, ctx } = setup(); + const storageMock = ctx.getMock(StorageRepository); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId }); + const { person: person1 } = await ctx.newPerson({ ownerId: user1.id }); + const { person: person2 } = await ctx.newPerson({ ownerId: user1.id }); + await ctx.newPerson({ + ownerId: user2.id, + personGroupId: person1.personGroupId, + }); + await ctx.newPerson({ + ownerId: user2.id, + personGroupId: person2.personGroupId, + }); + storageMock.unlink.mockResolvedValue(); + + const auth = factory.auth({ user: user1 }); + + await sut.mergePerson(auth, person1.personGroupId, { ids: [person2.personGroupId] }); + const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id })); + const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id })); + expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]); + expect(user2People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]); + }); + + it('should skip people with a different name', async () => { + const { sut, ctx } = setup(); + const storageMock = ctx.getMock(StorageRepository); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId }); + const { person: person1 } = await ctx.newPerson({ ownerId: user1.id }); + const { person: person2 } = await ctx.newPerson({ ownerId: user1.id }); + await ctx.newPerson({ + ownerId: user2.id, + personGroupId: person1.personGroupId, + name: 'Person 1', + }); + await ctx.newPerson({ + ownerId: user2.id, + personGroupId: person2.personGroupId, + name: 'Person 2', + }); + storageMock.unlink.mockResolvedValue(); + + const auth = factory.auth({ user: user1 }); + + await sut.mergePerson(auth, person1.personGroupId, { ids: [person2.personGroupId] }); + const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id })); + const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id })); + expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]); + expect(user2People).toEqual( + expect.arrayContaining([ + expect.objectContaining({ personGroupId: person1.personGroupId }), + expect.objectContaining({ personGroupId: person2.personGroupId }), + ]), + ); + }); + + it('should skip people with a different birthdate', async () => { + const { sut, ctx } = setup(); + const storageMock = ctx.getMock(StorageRepository); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser({ clusterGroupId: user1.clusterGroupId }); + const { person: person1 } = await ctx.newPerson({ ownerId: user1.id }); + const { person: person2 } = await ctx.newPerson({ ownerId: user1.id }); + await ctx.newPerson({ + ownerId: user2.id, + personGroupId: person1.personGroupId, + birthDate: DateTime.now().minus({ years: 1 }).toJSDate(), + }); + await ctx.newPerson({ + ownerId: user2.id, + personGroupId: person2.personGroupId, + birthDate: DateTime.now().minus({ years: 2 }).toJSDate(), + }); + storageMock.unlink.mockResolvedValue(); + + const auth = factory.auth({ user: user1 }); + + await sut.mergePerson(auth, person1.personGroupId, { ids: [person2.personGroupId] }); + const user1People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user1.id })); + const user2People = await Array.fromAsync(ctx.get(PersonRepository).getAll({ ownerId: user2.id })); + expect(user1People).toEqual([expect.objectContaining({ personGroupId: person1.personGroupId })]); + expect(user2People).toEqual( + expect.arrayContaining([ + expect.objectContaining({ personGroupId: person1.personGroupId }), + expect.objectContaining({ personGroupId: person2.personGroupId }), + ]), + ); + }); + }); + describe('createFace', () => { it('should store and retrieve the face as-is when there are no edits', async () => { const { sut, ctx } = setup(); @@ -101,7 +200,7 @@ describe(PersonService.name, () => { y: 50, width: 150, height: 150, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -114,7 +213,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 50, boundingBoxY1: 50, boundingBoxX2: 200, @@ -155,7 +254,7 @@ describe(PersonService.name, () => { y: 0, width: 100, height: 100, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -168,7 +267,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 100, @@ -186,7 +285,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 50, boundingBoxY1: 50, boundingBoxX2: 150, @@ -224,7 +323,7 @@ describe(PersonService.name, () => { y: 50, width: 10, height: 10, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -235,7 +334,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: expect.closeTo(25, 1), boundingBoxY1: expect.closeTo(50, 1), boundingBoxX2: expect.closeTo(35, 1), @@ -251,7 +350,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 50, boundingBoxY1: 65, boundingBoxX2: 60, @@ -289,7 +388,7 @@ describe(PersonService.name, () => { y: 25, width: 100, height: 50, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -300,7 +399,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 50, boundingBoxY1: 25, boundingBoxX2: 150, @@ -316,7 +415,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 50, boundingBoxY1: 25, boundingBoxX2: 150, @@ -363,7 +462,7 @@ describe(PersonService.name, () => { y: 25, width: 10, height: 20, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -374,7 +473,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: expect.closeTo(50, 1), boundingBoxY1: expect.closeTo(25, 1), boundingBoxX2: expect.closeTo(60, 1), @@ -390,7 +489,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 75, boundingBoxY1: 140, boundingBoxX2: 95, @@ -437,7 +536,7 @@ describe(PersonService.name, () => { y: 25, width: 75, height: 50, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -448,7 +547,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 25, boundingBoxY1: 25, boundingBoxX2: 100, @@ -464,7 +563,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 100, boundingBoxY1: 25, boundingBoxX2: 175, @@ -508,7 +607,7 @@ describe(PersonService.name, () => { y: 25, width: 15, height: 20, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -519,7 +618,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: expect.closeTo(50, 1), boundingBoxY1: expect.closeTo(25, 1), boundingBoxX2: expect.closeTo(65, 1), @@ -535,7 +634,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 25, boundingBoxY1: 50, boundingBoxX2: 45, @@ -588,7 +687,7 @@ describe(PersonService.name, () => { y: 50, width: 75, height: 50, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -599,7 +698,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 25, boundingBoxY1: 49, boundingBoxX2: 99, @@ -615,7 +714,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 50, boundingBoxY1: 75, boundingBoxX2: 100, @@ -659,7 +758,7 @@ describe(PersonService.name, () => { y: 10, width: 80, height: 80, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -670,7 +769,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, @@ -686,7 +785,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, @@ -730,7 +829,7 @@ describe(PersonService.name, () => { y: 10, width: 80, height: 80, - personId: person.id, + personId: person.personGroupId, assetId: asset.id, }; @@ -741,7 +840,7 @@ describe(PersonService.name, () => { await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 110, boundingBoxY1: 10, boundingBoxX2: 190, @@ -757,7 +856,7 @@ describe(PersonService.name, () => { await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ - person: expect.objectContaining({ id: person.id }), + person: expect.objectContaining({ id: person.personGroupId }), boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 044cd8d4f5..c612f45e47 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -69,11 +69,11 @@ describe(SearchService.name, () => { const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); const { person } = await ctx.newPerson({ ownerId: user.id }); - await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); const auth = factory.auth({ user: { id: user.id } }); - const result = await sut.searchStatistics(auth, { personIds: [person.id] }); + const result = await sut.searchStatistics(auth, { personIds: [person.personGroupId] }); expect(result).toEqual({ total: 1 }); }); @@ -85,7 +85,7 @@ describe(SearchService.name, () => { const auth = factory.auth({ user: { id: user.id } }); - const result = await sut.searchStatistics(auth, { personIds: [person.id] }); + const result = await sut.searchStatistics(auth, { personIds: [person.personGroupId] }); expect(result).toEqual({ total: 0 }); }); diff --git a/server/test/medium/specs/services/stack.service.spec.ts b/server/test/medium/specs/services/stack.service.spec.ts new file mode 100644 index 0000000000..68c06465b7 --- /dev/null +++ b/server/test/medium/specs/services/stack.service.spec.ts @@ -0,0 +1,44 @@ +import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { EventRepository } from 'src/repositories/event.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { StackRepository } from 'src/repositories/stack.repository'; +import { DB } from 'src/schema'; +import { StackService } from 'src/services/stack.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(StackService, { + database: db || defaultDatabase, + real: [AccessRepository, AssetRepository, StackRepository], + mock: [EventRepository, LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(StackService.name, () => { + describe('create', () => { + it('should not stack an asset of another user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const { asset: otherAsset } = await ctx.newAsset({ ownerId: otherUser.id }); + + await expect(sut.create(factory.auth({ user }), { assetIds: [asset.id, otherAsset.id] })).rejects.toThrow( + 'Not found or no asset.update access', + ); + await expect( + ctx.database.selectFrom('stack').selectAll().where('ownerId', '=', user.id).execute(), + ).resolves.toEqual([]); + }); + }); +}); diff --git a/server/test/medium/specs/services/tag.service.spec.ts b/server/test/medium/specs/services/tag.service.spec.ts index 989e4f535f..52d002eed2 100644 --- a/server/test/medium/specs/services/tag.service.spec.ts +++ b/server/test/medium/specs/services/tag.service.spec.ts @@ -22,12 +22,61 @@ const setup = (db?: Kysely) => { }); }; +/** A tag owned by one user, plus another user's auth to attempt access with */ +const newTagOfAnotherUser = async (ctx: ReturnType['ctx']) => { + const { user } = await ctx.newUser(); + const { user: otherUser } = await ctx.newUser(); + const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] }); + + return { tag, auth: factory.auth({ user }), otherAuth: factory.auth({ user: otherUser }) }; +}; + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); describe(TagService.name, () => { + describe('get', () => { + it('should not return a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.get(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.read access'); + }); + }); + + describe('update', () => { + it('should not update a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.update(otherAuth, tag.id, { color: '#000000' })).rejects.toThrow( + 'Not found or no tag.update access', + ); + }); + }); + + describe('remove', () => { + it('should not remove a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx); + + await expect(sut.remove(otherAuth, tag.id)).rejects.toThrow('Not found or no tag.delete access'); + await expect(sut.get(auth, tag.id)).resolves.toEqual(expect.objectContaining({ id: tag.id })); + }); + }); + describe('addAssets', () => { + it('should not add assets to a tag of another user', async () => { + const { sut, ctx } = setup(); + const { tag, otherAuth } = await newTagOfAnotherUser(ctx); + const { asset } = await ctx.newAsset({ ownerId: otherAuth.user.id }); + + await expect(sut.addAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no tag.asset access', + ); + }); + it('should lock exif column', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); @@ -53,6 +102,22 @@ describe(TagService.name, () => { await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id); }); }); + + describe('removeAssets', () => { + it('should not remove assets from a tag of another user', async () => { + const { sut, ctx } = setup(); + ctx.getMock(EventRepository).emit.mockResolvedValue(); + const { tag, auth, otherAuth } = await newTagOfAnotherUser(ctx); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + await sut.addAssets(auth, tag.id, { ids: [asset.id] }); + + await expect(sut.removeAssets(otherAuth, tag.id, { ids: [asset.id] })).rejects.toThrow( + 'Not found or no tag.asset access', + ); + await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id); + }); + }); + describe('deleteEmptyTags', () => { it('single tag exists, not connected to any assets, and is deleted', async () => { const { sut, ctx } = setup(); diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index 85ab9a8c0d..311a5cb846 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -1,6 +1,7 @@ import { Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { ImmichEnvironment, JobName, JobStatus, UserAvatarColor } from 'src/enum'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { EventRepository } from 'src/repositories/event.repository'; @@ -12,7 +13,7 @@ import { DB } from 'src/schema'; import { UserService } from 'src/services/user.service'; import { HumanReadableSize } from 'src/utils/bytes'; import { mediumFactory, newMediumService } from 'test/medium.factory'; -import { factory } from 'test/small.factory'; +import { factory, newUuid } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; const userLicense = { @@ -28,7 +29,7 @@ const setup = (db?: Kysely) => { return newMediumService(UserService, { database: db || defaultDatabase, - real: [CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository], + real: [ClusterGroupRepository, CryptoRepository, ConfigRepository, SystemMetadataRepository, UserRepository], mock: [LoggingRepository, JobRepository, EventRepository], }); }; @@ -44,7 +45,7 @@ describe(UserService.name, () => { it('should create a user', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); - const user = mediumFactory.userInsert(); + const user = mediumFactory.userInsert({ clusterGroupId: newUuid() }); const created = await sut.createUser({ name: user.name, email: user.email }); expect(created).toEqual(expect.objectContaining({ name: user.name, email: user.email })); @@ -54,7 +55,7 @@ describe(UserService.name, () => { it('should reject user with duplicate email', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); - const user = mediumFactory.userInsert(); + const user = mediumFactory.userInsert({ clusterGroupId: newUuid() }); await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email }); await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('Email is not available'); }); @@ -62,7 +63,7 @@ describe(UserService.name, () => { it('should not return password', async () => { const { sut, ctx } = setup(); ctx.getMock(EventRepository).emit.mockResolvedValue(); - const dto = mediumFactory.userInsert({ password: 'password' }); + const dto = mediumFactory.userInsert({ clusterGroupId: newUuid(), password: 'password' }); const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' }); expect((user as any).password).toBeUndefined(); }); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index 74d4c536f1..6e8e4d7039 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -23,7 +23,7 @@ describe(SyncEntityType.AssetFaceV2, () => { const { auth, ctx } = await setup(); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); - const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ @@ -32,7 +32,7 @@ describe(SyncEntityType.AssetFaceV2, () => { data: expect.objectContaining({ id: assetFace.id, assetId: asset.id, - personId: person.id, + personId: person.personGroupId, imageWidth: assetFace.imageWidth, imageHeight: assetFace.imageHeight, boundingBoxX1: assetFace.boundingBoxX1, @@ -103,7 +103,7 @@ describe(SyncEntityType.AssetFaceV2, () => { const { auth, ctx } = await setup(); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); - const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ @@ -112,7 +112,7 @@ describe(SyncEntityType.AssetFaceV2, () => { data: expect.objectContaining({ id: assetFace.id, assetId: asset.id, - personId: person.id, + personId: person.personGroupId, imageWidth: assetFace.imageWidth, imageHeight: assetFace.imageHeight, boundingBoxX1: assetFace.boundingBoxX1, @@ -182,7 +182,7 @@ describe(SyncEntityType.AssetFaceV2, () => { const personRepo = ctx.get(PersonRepository); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); - const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personGroupId: person.personGroupId }); let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ @@ -191,7 +191,7 @@ describe(SyncEntityType.AssetFaceV2, () => { data: expect.objectContaining({ id: assetFace.id, assetId: asset.id, - personId: person.id, + personId: person.personGroupId, imageWidth: assetFace.imageWidth, imageHeight: assetFace.imageHeight, boundingBoxX1: assetFace.boundingBoxX1, diff --git a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts index 02d6808ecf..7d43bc6eb1 100644 --- a/server/test/medium/specs/sync/sync-asset-ocr.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-ocr.spec.ts @@ -3,6 +3,7 @@ import { SyncEntityType, SyncRequestType } from 'src/enum'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { DB } from 'src/schema'; import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -303,6 +304,61 @@ describe(SyncEntityType.AssetOcrV1, () => { await ctx.syncAckAll(auth, updatedResponse); await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]); }); + + it('should sync in-place OCR visibility toggles (e.g. after an asset edit)', async () => { + const { auth, user, ctx } = await setup(); + + const ocrRepo = ctx.get(OcrRepository); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ocrRepo.upsert( + asset.id, + [ + { + assetId: asset.id, + x1: 0.1, + y1: 0.2, + x2: 0.9, + y2: 0.2, + x3: 0.9, + y3: 0.8, + x4: 0.1, + y4: 0.8, + boxScore: 0.95, + textScore: 0.92, + text: 'Hello World', + isVisible: true, + }, + ], + 'Hello World', + ); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]); + const ocrId = (response[0] as { data: { id: string } }).data.id; + await ctx.syncAckAll(auth, response); + + await ocrRepo.updateOcrVisibilities( + asset.id, + [], + [factory.assetOcr({ id: ocrId, assetId: asset.id, text: 'Hello World' })], + ); + + const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.AssetOcrV1]); + expect(updatedResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: ocrId, + assetId: asset.id, + isVisible: false, + }), + type: 'AssetOcrV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, updatedResponse); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.AssetOcrV1]); + }); }); describe(SyncEntityType.AssetOcrDeleteV1, () => { diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts index 6fdb5a58f2..bb752344ad 100644 --- a/server/test/medium/specs/sync/sync-person.spec.ts +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -28,7 +28,7 @@ describe(SyncEntityType.PersonV1, () => { { ack: expect.any(String), data: expect.objectContaining({ - id: person.id, + id: person.personGroupId, name: person.name, isHidden: person.isHidden, birthDate: person.birthDate, @@ -50,14 +50,14 @@ describe(SyncEntityType.PersonV1, () => { const { auth, ctx } = await setup(); const personRepo = ctx.get(PersonRepository); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); - await personRepo.delete([person.id]); + await personRepo.delete([person.personGroupId], person.ownerId); const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); expect(response).toEqual([ { ack: expect.any(String), data: { - personId: person.id, + personId: person.personGroupId, }, type: 'PersonDeleteV1', }, @@ -82,7 +82,7 @@ describe(SyncEntityType.PersonV1, () => { ]); await ctx.assertSyncIsComplete(auth, [SyncRequestType.PeopleV1]); - await personRepo.delete([person.id]); + await personRepo.delete([person.personGroupId], person.ownerId); expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toEqual([ expect.objectContaining({ type: SyncEntityType.PersonDeleteV1 }), diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index f723113bd1..90b9f64e0b 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -16,6 +16,16 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkCreateAccess: vitest.fn().mockResolvedValue(new Set()), }, + clusterGroup: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + checkInviteAccess: vitest.fn().mockResolvedValue(new Set()), + }, + + clusterGroupRequest: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + checkGroupAccess: vitest.fn().mockResolvedValue(new Set()), + }, + asset: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()), @@ -23,6 +33,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()), }, + assetFile: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + album: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkSharedAlbumAccess: vitest.fn().mockResolvedValue(new Set()), diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index ae83727d70..14f74d1a25 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,7 +1,7 @@ -import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database'; +import { AuthApiKey, AuthSharedLink, AuthUser, Library, UserAdmin } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; -import { AssetFileType, Permission, UserStatus } from 'src/enum'; +import { Permission, UserStatus } from 'src/enum'; import { v4, v7 } from 'uuid'; import { expect } from 'vitest'; @@ -168,31 +168,6 @@ const versionHistoryFactory = () => ({ version: '1.123.45', }); -const assetSidecarWriteFactory = () => { - const id = newUuid(); - return { - id, - originalPath: '/path/to/original-path.jpg.xmp', - tags: [], - files: [ - { - id: newUuid(), - path: '/path/to/original-path.jpg.xmp', - type: AssetFileType.Sidecar, - isEdited: false, - }, - ], - exifInfo: { - assetId: id, - description: 'this is a description', - latitude: 12, - longitude: 12, - dateTimeOriginal: '2023-11-22T04:56:12.196Z', - timeZone: 'UTC-6', - } as unknown as Exif, - }; -}; - const assetOcrFactory = ( ocr: { id?: string; @@ -210,10 +185,12 @@ const assetOcrFactory = ( textScore?: number; text?: string; isVisible?: boolean; + updatedAt?: Date; } = {}, ) => ({ id: newUuid(), updateId: newUuidV7(), + updatedAt: newDate(), assetId: newUuid(), x1: 0.1, y1: 0.2, @@ -236,9 +213,6 @@ export const factory = { library: libraryFactory, queueStatistics: queueStatisticsFactory, versionHistory: versionHistoryFactory, - jobAssets: { - sidecarWrite: assetSidecarWriteFactory, - }, uuid: newUuid, buffer: () => Buffer.from('this is a fake buffer'), date: newDate, diff --git a/server/test/utils.ts b/server/test/utils.ts index 2f32f3b412..e273d72a36 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -24,8 +24,10 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { ClusterGroupRepository } from 'src/repositories/cluster-group.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; import { CronRepository } from 'src/repositories/cron.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; @@ -75,6 +77,7 @@ import { AuthService } from 'src/services/auth.service'; import { BaseService } from 'src/services/base.service'; import { RepositoryInterface } from 'src/types'; import { getKyselyConfig } from 'src/utils/database'; +import { ClusterGroupFactory } from 'test/factories/cluster-group.factory'; 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'; @@ -95,7 +98,9 @@ export type ControllerContext = { close: () => Promise; }; -export const controllerSetup = async (controller: new (...args: any[]) => unknown, providers: Provider[]) => { +type ControllerClass = new (...args: any[]) => unknown; + +export const controllerSetup = async (controller: ControllerClass | ControllerClass[], providers: Provider[]) => { const noopInterceptor = { intercept: (ctx: never, next: CallHandler) => next.handle() }; const upload = multer({ storage: multer.memoryStorage() }); const memoryFileInterceptor = { @@ -117,7 +122,7 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow }, }; const moduleRef = await Test.createTestingModule({ - controllers: [controller], + controllers: Array.isArray(controller) ? controller : [controller], providers: [ { provide: APP_FILTER, useClass: GlobalExceptionFilter }, { provide: APP_PIPE, useClass: ZodValidationPipe }, @@ -235,7 +240,9 @@ export type ServiceOverrides = { app: AppRepository; asset: AssetRepository; assetEdit: AssetEditRepository; + assetFile: AssetFileRepository; assetJob: AssetJobRepository; + clusterGroup: ClusterGroupRepository; config: ConfigRepository; cron: CronRepository; crypto: CryptoRepository; @@ -318,7 +325,9 @@ export const getMocks = () => { albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), assetEdit: automock(AssetEditRepository), + assetFile: automock(AssetFileRepository), assetJob: automock(AssetJobRepository), + clusterGroup: automock(ClusterGroupRepository), app: automock(AppRepository, { strict: false }), config: newConfigRepositoryMock(), database: databaseMock, @@ -369,6 +378,9 @@ export const getMocks = () => { workflow: automock(WorkflowRepository, { strict: true }), }; + // every new user gets a cluster group, which is incidental to most tests + mocks.clusterGroup.create.mockResolvedValue(ClusterGroupFactory.create()); + return mocks; }; @@ -388,7 +400,9 @@ export const newTestService = ( overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), overrides.assetEdit || (mocks.assetEdit as As), + overrides.assetFile || (mocks.assetFile as As), overrides.assetJob || (mocks.assetJob as As), + overrides.clusterGroup || (mocks.clusterGroup as As), overrides.config || (mocks.config as As as ConfigRepository), overrides.cron || (mocks.cron as As), overrides.crypto || (mocks.crypto as As), @@ -541,10 +555,8 @@ export const mockDuplex = if (error) { duplex.destroy(error as Error); } else if (exitCode === 0) { - /* eslint-disable unicorn/prefer-single-call */ duplex.push(stdout); duplex.push(null); - /* eslint-enable unicorn/prefer-single-call */ } else { duplex.destroy(new Error(`${command} non-zero exit code (${exitCode})\n${stderr}`)); } @@ -553,43 +565,6 @@ export const mockDuplex = return duplex; }; -export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => { - const stdoutStream = new Readable({ - read() { - this.push(stdout); // write mock data to stdout - this.push(null); // end stream - }, - }); - - return { - stdout: stdoutStream, - stderr: new Readable({ - read() { - this.push(stderr); // write mock data to stderr - this.push(null); // end stream - }, - }), - stdin: new Writable({ - write(chunk, encoding, callback) { - callback(); - }, - }), - exitCode, - on: vitest.fn((event, callback: any) => { - if (event === 'close') { - stdoutStream.once('end', () => callback(0)); - } - if (event === 'error' && error) { - stdoutStream.once('end', () => callback(error)); - } - if (event === 'exit') { - stdoutStream.once('end', () => callback(exitCode)); - } - }), - kill: vitest.fn(), - } as unknown as ChildProcessWithoutNullStreams; -}); - export async function* makeStream(items: T[] = []): AsyncGenerator { for (const item of items) { await Promise.resolve(); diff --git a/web/eslint.config.js b/web/eslint.config.js index 5974eaf68f..c7075db443 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -10,10 +10,7 @@ import parser from 'svelte-eslint-parser'; import typescriptEslint from 'typescript-eslint'; import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import './lint-env.js'; export default typescriptEslint.config( ...eslintPluginSvelte.configs.recommended, @@ -29,7 +26,7 @@ export default typescriptEslint.config( 'error', { browserslist: fs - .readFileSync(path.join(__dirname, '.browserslistrc'), 'utf8') + .readFileSync(path.join(import.meta.dirname, '.browserslistrc'), 'utf8') .split('\n') .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')), @@ -40,7 +37,7 @@ export default typescriptEslint.config( parser, parserOptions: { project: ['./tsconfig.json'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, }, }, // ignores: ['**/service-worker/**'], @@ -72,6 +69,7 @@ export default typescriptEslint.config( '**/yarn.lock', '**/svelte.config.js', 'eslint.config.js', + 'lint-env.js', 'tailwind.config.js', 'coverage', 'vite.config.ts', @@ -96,7 +94,7 @@ export default typescriptEslint.config( parserOptions: { extraFileExtensions: ['.svelte'], - tsconfigRootDir: __dirname, + tsconfigRootDir: import.meta.dirname, project: ['./tsconfig.json'], }, }, @@ -148,6 +146,7 @@ export default typescriptEslint.config( 'unicorn/prefer-minimal-ternary': 'off', 'unicorn/no-empty-file': 'off', 'unicorn/prefer-simple-condition-first': 'off', + 'unicorn/single-line-block-comment-style': ['error', 'single-line'], // prefer the typescript-eslint type-aware version 'unicorn/require-array-sort-compare': 'off', '@typescript-eslint/require-array-sort-compare': 'error', diff --git a/web/lint-env.js b/web/lint-env.js new file mode 100644 index 0000000000..3394cdb783 --- /dev/null +++ b/web/lint-env.js @@ -0,0 +1,5 @@ +// Must be imported before eslint-plugin-svelte / svelte-eslint-parser. +// Enables svelte-eslint-parser's ts.sys hook so typescript-eslint can reuse +// its TypeScript program across .svelte files instead of rebuilding it per +// file (~4x faster lint). See https://github.com/sveltejs/eslint-plugin-svelte/issues/1552 +process.env.SVELTE_ESLINT_PARSER_EXPERIMENTAL_TS_SYS_HOOK ??= '1'; diff --git a/web/package.json b/web/package.json index d709ca0de6..28695c6287 100644 --- a/web/package.json +++ b/web/package.json @@ -78,7 +78,7 @@ "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/kit": "^2.56.1", - "@sveltejs/vite-plugin-svelte": "7.2.0", + "@sveltejs/vite-plugin-svelte": "7.3.0", "@tailwindcss/vite": "^4.2.4", "@testing-library/jest-dom": "^7.0.0", "@testing-library/svelte": "^5.2.8", @@ -90,6 +90,7 @@ "@types/lodash-es": "^4.17.12", "@types/luxon": "^3.4.2", "@types/qrcode": "^1.5.5", + "@typescript/native": "npm:typescript@^7.0.2", "@vitest/coverage-v8": "^4.0.0", "dotenv": "^17.0.0", "eslint": "^10.2.1", @@ -97,7 +98,7 @@ "eslint-plugin-better-tailwindcss": "^4.5.0", "eslint-plugin-compat": "^7.0.0", "eslint-plugin-svelte": "^3.12.4", - "eslint-plugin-unicorn": "^72.0.0", + "eslint-plugin-unicorn": "^73.0.0", "factory.ts": "^1.4.1", "globals": "^17.0.0", "happy-dom": "^20.0.0", @@ -105,11 +106,10 @@ "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^4.0.0", "rollup-plugin-visualizer": "^7.0.0", - "svelte": "5.56.8", + "svelte": "5.56.9", "svelte-check": "^4.4.6", - "svelte-eslint-parser": "^1.3.3", + "svelte-eslint-parser": "^1.8.1", "tailwindcss": "^4.2.4", - "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-eslint": "^8.45.0", "vite": "^8.0.0", diff --git a/web/src/lib/__mocks__/jsdom-url.mock.ts b/web/src/lib/__mocks__/jsdom-url.mock.ts deleted file mode 100644 index d65da98141..0000000000 --- a/web/src/lib/__mocks__/jsdom-url.mock.ts +++ /dev/null @@ -1,9 +0,0 @@ -const createObjectURLMock = vi.fn(); - -// eslint-disable-next-line unicorn/no-top-level-side-effects -Object.defineProperty(URL, 'createObjectURL', { - writable: true, - value: createObjectURLMock, -}); - -export { createObjectURLMock }; diff --git a/web/src/lib/attachments/drag-and-drop.svelte.ts b/web/src/lib/attachments/drag-and-drop.svelte.ts deleted file mode 100644 index 28afd25615..0000000000 --- a/web/src/lib/attachments/drag-and-drop.svelte.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Attachment } from 'svelte/attachments'; - -export interface DragAndDropOptions { - index: number; - onDragStart?: (index: number) => void; - onDragEnter?: (index: number) => void; - onDrop?: (e: DragEvent, index: number) => void; - onDragEnd?: () => void; - isDragging?: boolean; - isDragOver?: boolean; -} - -export function dragAndDrop(options: DragAndDropOptions): Attachment { - return (node: Element) => { - const element = node as HTMLElement; - const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options; - - const isFormElement = (el: HTMLElement) => { - return ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName); - }; - - const handleDragStart = (e: DragEvent) => { - // Prevent drag if it originated from an input, textarea, or select element - const target = e.target as HTMLElement; - if (isFormElement(target)) { - e.preventDefault(); - return; - } - onDragStart?.(index); - }; - - const handleDragEnter = () => { - onDragEnter?.(index); - }; - - const handleDragOver = (e: DragEvent) => { - e.preventDefault(); - }; - - const handleDrop = (e: DragEvent) => { - onDrop?.(e, index); - }; - - const handleDragEnd = () => { - onDragEnd?.(); - }; - - // Disable draggable when focusing on form elements (fixes Firefox input interaction) - const handleFocusIn = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if (isFormElement(target)) { - element.setAttribute('draggable', 'false'); - } - }; - - const handleFocusOut = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if (isFormElement(target)) { - element.setAttribute('draggable', 'true'); - } - }; - - // Update classes based on drag state - const updateClasses = (dragging: boolean, dragOver: boolean) => { - // Remove all drag-related classes first - element.classList.remove('opacity-50', 'border-light-500', 'border-solid'); - - // Add back only the active ones - if (dragging) { - element.classList.add('opacity-50'); - } - - if (dragOver) { - element.classList.add('border-light-500', 'border-solid'); - element.classList.remove('border-transparent'); - } else { - element.classList.add('border-transparent'); - } - }; - - element.setAttribute('draggable', 'true'); - element.setAttribute('role', 'button'); - element.setAttribute('tabindex', '0'); - - element.addEventListener('dragstart', handleDragStart); - element.addEventListener('dragenter', handleDragEnter); - element.addEventListener('dragover', handleDragOver); - element.addEventListener('drop', handleDrop); - element.addEventListener('dragend', handleDragEnd); - element.addEventListener('focusin', handleFocusIn); - element.addEventListener('focusout', handleFocusOut); - - updateClasses(isDragging || false, isDragOver || false); - - return () => { - element.removeEventListener('dragstart', handleDragStart); - element.removeEventListener('dragenter', handleDragEnter); - element.removeEventListener('dragover', handleDragOver); - element.removeEventListener('drop', handleDrop); - element.removeEventListener('dragend', handleDragEnd); - element.removeEventListener('focusin', handleFocusIn); - element.removeEventListener('focusout', handleFocusOut); - }; - }; -} diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte index 65d115179e..c4e20081dd 100644 --- a/web/src/lib/components/SchemaConfiguration.svelte +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -1,6 +1,7 @@ + + { + if (option && !tagIds.includes(option.value)) { + tagIds.push(option.value); + } + }} + label={$t('tags')} + defaultFirstOption + options={tags.map((tag) => ({ label: tag.value, value: tag.id }))} + placeholder={$t('search_tags')} +/> + +
+ {#each tagIds as id, index (id)} + t.id === id)?.name ?? id} onRemove={() => tagIds.splice(index, 1)} /> + {/each} +
diff --git a/web/src/lib/components/album-page/AlbumViewer.svelte b/web/src/lib/components/album-page/AlbumViewer.svelte index 7f294e2c20..2f1b43aee3 100644 --- a/web/src/lib/components/album-page/AlbumViewer.svelte +++ b/web/src/lib/components/album-page/AlbumViewer.svelte @@ -101,7 +101,7 @@ {#if sharedLink.allowDownload} - + {/if} {:else} diff --git a/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte b/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte index 6290209e28..fea7946c20 100644 --- a/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanelPeople.svelte @@ -57,45 +57,49 @@ ); -{#if !authManager.isSharedLink && isOwner} +{#if !authManager.isSharedLink}
-
- {$t('people')} -
- {#if people.some((person) => person.isHidden)} - assetViewerManager.toggleHiddenPeople()} - /> - {/if} - assetViewerManager.toggleFaceEditMode()} - /> + {#if isOwner || visiblePeople.length > 0} +
+ {$t('people')} +
+ {#if isOwner} + {#if people.some((person) => person.isHidden)} + assetViewerManager.toggleHiddenPeople()} + /> + {/if} + assetViewerManager.toggleFaceEditMode()} + /> - {#if faceManager.data.length > 0} - assetViewerManager.openEditFacesPanel()} - /> - {/if} + {#if faceManager.data.length > 0} + assetViewerManager.openEditFacesPanel()} + /> + {/if} + {/if} +
-
+ {/if}
{#each visiblePeople as person (person.id)} diff --git a/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte b/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte deleted file mode 100644 index 9817870d93..0000000000 --- a/web/src/lib/components/asset-viewer/editor/transform-tool/CropPreset.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -
  • - -
  • diff --git a/web/src/lib/components/share-page/IndividualSharedViewer.svelte b/web/src/lib/components/share-page/IndividualSharedViewer.svelte index a119f4ffea..7e7f056e1f 100644 --- a/web/src/lib/components/share-page/IndividualSharedViewer.svelte +++ b/web/src/lib/components/share-page/IndividualSharedViewer.svelte @@ -44,7 +44,7 @@ }); const downloadAssets = async () => { - await downloadArchive(`immich-shared.zip`, { assetIds: assets.map((asset) => asset.id) }); + await downloadArchive(`immich-shared`, { assetIds: assets.map((asset) => asset.id) }); }; const handleUploadAssets = async (files: File[] = []) => { @@ -93,7 +93,7 @@ onclick={handleSelectAll} /> {#if sharedLink?.allowDownload} - + {/if} {#if isOwned} diff --git a/web/src/lib/components/shared-components/Combobox.svelte b/web/src/lib/components/shared-components/Combobox.svelte index 388317a4ef..83d56a550e 100644 --- a/web/src/lib/components/shared-components/Combobox.svelte +++ b/web/src/lib/components/shared-components/Combobox.svelte @@ -268,7 +268,9 @@ - +{#if !hideLabel} + +{/if}
    - - { - event.setMaxZoom(18); - event.on('click', handleMapClick); - if (!simplified) { - event.addControl(new GlobeControl(), 'top-left'); - } - }} - bind:map -> - {#snippet children({ map }: { map: Map })} - {#if showSimpleControls} - - {#if !simplified} - - - + + { + event.setMaxZoom(18); + event.on('click', handleMapClick); + if (!simplified) { + event.addControl(new GlobeControl(), 'top-left'); + } + }} + bind:map + > + {#snippet children({ map }: { map: Map })} + {#if showSimpleControls} + + + {#if !simplified} + + + + {/if} {/if} - {/if} - {#if showSettings} - - - - - - - - {/if} + {#if showSettings} + + + + + + + + {/if} - {#if onOpenInMapView && showSimpleControls} - - - onOpenInMapView()}> - - - - - {/if} + {#if onOpenInMapView && showSimpleControls} + + + onOpenInMapView()}> + + + + + {/if} - asFeature(marker)) ?? [], - }} - id="geojson" - cluster={{ radius: 35, maxZoom: 18 }} - > - handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))} - > - {#snippet children({ feature })} -
    - {feature.properties?.point_count?.toLocaleString()} -
    - {/snippet} -
    - { - if (!popup) { - handleAssetClick(event.feature.properties?.id, map); - } + asFeature(marker)) ?? [], }} + id="geojson" + cluster={{ radius: 35, maxZoom: 18 }} > - {#snippet children({ feature }: { feature: Feature })} - {#if useLocationPin} - - {:else} - {feature.properties?.city - {/if} - {#if popup} - - {@render popup({ marker: asMarker(feature) })} - - {/if} - {/snippet} - -
    + handlePromiseError(handleClusterClick(event.feature.properties?.cluster_id, map))} + > + {#snippet children({ feature })} +
    + {feature.properties?.point_count?.toLocaleString()} +
    + {/snippet} +
    + { + if (!popup) { + handleAssetClick(event.feature.properties?.id, map); + } + }} + > + {#snippet children({ feature }: { feature: Feature })} + {#if useLocationPin} + + {:else} + {feature.properties?.city + {/if} + {#if popup} + + {@render popup({ marker: asMarker(feature) })} + + {/if} + {/snippet} + + + {/snippet} +
    + + {#snippet failed()} + + + {$t('errors.unable_to_load_map_description')} + + {/snippet} -
    + diff --git a/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte b/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte index 79cfff858a..4e6929a87e 100644 --- a/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/NotificationPanel.svelte @@ -2,6 +2,8 @@ import { goto } from '$app/navigation'; import { focusTrap } from '$lib/actions/focus-trap'; import NotificationItem from '$lib/components/shared-components/navigation-bar/NotificationItem.svelte'; + import { OpenQueryParam } from '$lib/constants'; + import { Route } from '$lib/route'; import { notificationManager } from '$lib/stores/notification-manager.svelte'; import { handleError } from '$lib/utils/handle-error'; import { NotificationType, type NotificationDto } from '@immich/sdk'; @@ -50,6 +52,11 @@ break; } + case NotificationType.ClusterGroupRequest: { + await goto(Route.userSettings({ isOpen: OpenQueryParam.SHARING })); + break; + } + default: { break; } diff --git a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte index b099020e77..fc6e6f6e2d 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchBar.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchBar.svelte @@ -1,37 +1,29 @@ - input?.select() }, - { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, - ]} -/> + input?.select() }]} />
    (value = '')} + onreset={() => (searchManager.filter.query = '')} {onsubmit} onfocusin={onFocusIn} role="search" @@ -276,27 +140,26 @@ type="text" name="q" id="main-search-bar" - class="w-full border-2 py-4 ps-14 text-immich-fg/75 transition-all max-md:py-2 dark:text-immich-dark-fg + class="w-full border-b py-4 ps-14 text-immich-fg/75 transition-all max-md:py-2 dark:text-immich-dark-fg {showClearIcon ? 'pe-22.5' : 'pe-14'} - {grayTheme ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} - {showSuggestions && isSearchSuggestions ? 'rounded-t-3xl' : 'rounded-3xl bg-gray-200'} - {searchStore.isSearchEnabled ? 'border-gray-200 bg-white dark:border-gray-700' : 'border-transparent'}" + {grayTheme || showSuggestions ? 'dark:bg-immich-dark-gray' : 'dark:bg-immich-dark-bg'} + {showSuggestions ? 'rounded-t-3xl shadow-[0_8px_20px_rgba(0,0,0,0.12)]' : 'rounded-3xl bg-gray-200'} + {searchStore.isSearchEnabled ? 'border-light-200 bg-white dark:border-dark-600' : 'border-transparent'}" placeholder={$t('search_your_photos')} required pattern="^(?!m:$).*$" - bind:value + bind:value={searchManager.filter.query} bind:this={input} onfocus={openDropdown} oninput={onInput} role="combobox" aria-controls={listboxId} aria-activedescendant={selectedId ?? ''} - aria-expanded={showSuggestions && isSearchSuggestions} + aria-expanded={showSuggestions} aria-autocomplete="list" aria-describedby={searchTypeId} use:shortcuts={[ { shortcut: { key: 'Escape' }, onShortcut: onEscape }, - { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, { shortcut: { key: 'ArrowUp' }, onShortcut: () => onArrow(-1) }, { shortcut: { key: 'ArrowDown' }, onShortcut: () => onArrow(1) }, { shortcut: { key: 'Enter' }, onShortcut: onEnter, preventDefault: false }, @@ -304,61 +167,18 @@ ]} /> - - clearSearchTerm(searchTerm)} onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))} onActiveSelectionChange={(id) => (selectedId = id)} + onSearch={() => handleSearch()} />
    -
    0} - > -
    - - - {#if showSearchTypeDropdown} -
    - {#each searchTypes as searchType (searchType.value)} - - {/each} -
    - {/if} -
    -
    - {#if showClearIcon}
    - -
    - -
    diff --git a/web/src/lib/components/shared-components/search-bar/SearchButton.svelte b/web/src/lib/components/shared-components/search-bar/SearchButton.svelte new file mode 100644 index 0000000000..820af91f4b --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchButton.svelte @@ -0,0 +1,31 @@ + + + diff --git a/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte index b1d93210cf..3fe6d0d088 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchCameraSection.svelte @@ -1,16 +1,12 @@
    - {$t('camera')} -
    + {$t('camera')} +
    - import type { SearchDateFilter } from '$lib/types'; + import { getSearchDatePreset, SearchDatePreset } from './search-bar-utils'; import { DatePicker, Text } from '@immich/ui'; + import { DateTime } from 'luxon'; import { t } from 'svelte-i18n'; + import { searchManager } from '$lib/managers/search-manager.svelte'; + import SearchButton from './SearchButton.svelte'; - type Props = { - filters: SearchDateFilter; - }; - - let { filters = $bindable() }: Props = $props(); - + let filters = $derived(searchManager.filter.date); let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore); + + let currentPreset = $derived(getSearchDatePreset(filters.takenAfter, filters.takenBefore)); + + const setPreset = (preset: SearchDatePreset) => { + if (currentPreset === preset) { + currentPreset = filters.takenAfter = filters.takenBefore = undefined; + return; + } + + switch (preset) { + case SearchDatePreset.ThisYear: { + filters.takenAfter = DateTime.utc().startOf('year'); + filters.takenBefore = DateTime.utc().endOf('year'); + break; + } + case SearchDatePreset.LastYear: { + filters.takenAfter = DateTime.utc().minus({ years: 1 }).startOf('year'); + filters.takenBefore = DateTime.utc().minus({ years: 1 }).endOf('year'); + break; + } + case SearchDatePreset.Last30Days: { + filters.takenAfter = DateTime.utc().minus({ days: 30 }).startOf('day'); + filters.takenBefore = DateTime.utc().endOf('day'); + break; + } + case SearchDatePreset.Custom: { + filters.takenAfter = filters.takenBefore = undefined; + break; + } + } + + currentPreset = preset; + }; -
    -
    -
    - {$t('start_date')} - -
    - -
    - {$t('end_date')} - -
    +
    + {$t('search_filter_date_description')} +
    + setPreset(SearchDatePreset.ThisYear)}>{$t('search_filter_date_this_year')} + setPreset(SearchDatePreset.LastYear)}>{$t('search_filter_date_last_year')} + setPreset(SearchDatePreset.Last30Days)}>{$t('search_filter_date_last_30_days')} + setPreset(SearchDatePreset.Custom)}>{$t('search_filter_date_custom')}
    + {#if currentPreset === SearchDatePreset.Custom} +
    +
    + {$t('start_date')} + +
    +
    + {$t('end_date')} + +
    +
    + {/if} {#if invalid} {$t('start_date_before_end_date')} {/if} diff --git a/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte b/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte index c011d6a975..db1a087282 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchDisplaySection.svelte @@ -1,32 +1,25 @@
    - {$t('display_options')} - -
    -
    - -
    -
    - -
    -
    - -
    + {$t('library')} +
    + (filters.isFavorite = !filters.isFavorite)} + >{$t('favorites')} + (filters.isArchive = !filters.isArchive)} + >{$t('archive')} + (filters.isNotInAlbum = !filters.isNotInAlbum)} + >{$t('search_filter_display_option_not_in_album')}
    diff --git a/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte new file mode 100644 index 0000000000..a3dc10a6d3 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchFilters.svelte @@ -0,0 +1,264 @@ + + +
    + {#if isOpen} +
    + +
    + {$t('filter_by')} +
    + {#each filters as item (item.name)} + (activeFilter = item.name)} + > + {item.activeTitle() ?? item.title} + + {/each} +
    +
    + {#if activeFilter === 'advanced'} +
    +
    + + {#if authManager.authenticated && authManager.preferences.ratings.enabled} + + {/if} + +
    + {:else if activeFilter} +
    + {#if activeFilter === 'type'} + + {:else if activeFilter === 'people'} + + {:else if activeFilter === 'date'} + + {:else if activeFilter === 'places'} + + {:else if activeFilter === 'tags'} + + {:else if activeFilter === 'media'} + + {/if} +
    + {/if} +
    +
    + +
    + + +
    +
    + {/if} +
    diff --git a/web/src/lib/components/shared-components/search-bar/SearchHistoryBox.svelte b/web/src/lib/components/shared-components/search-bar/SearchHistoryBox.svelte deleted file mode 100644 index a1e3d9328f..0000000000 --- a/web/src/lib/components/shared-components/search-bar/SearchHistoryBox.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - -
    - {#if isOpen && isSearchSuggestions} -
    -
    - {$t('recent_searches')} - {#if showClearAll} - - {/if} -
    - - {#each filteredSearchTerms as savedSearchTerm, i (i)} - {@const index = showClearAll ? i + 1 : i} -
    -
    - -
    handleSelect(savedSearchTerm)} - role="option" - tabindex="-1" - aria-selected={selectedIndex === index} - aria-label={savedSearchTerm} - > - - {savedSearchTerm} -
    -
    - handleClearSingle(savedSearchTerm)} - /> -
    -
    -
    - {/each} -
    - {/if} -
    diff --git a/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte b/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte new file mode 100644 index 0000000000..4776f28046 --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/SearchHistorySection.svelte @@ -0,0 +1,135 @@ + + +
    + {#if isSearchSuggestions} +
    + {$t('recent_searches')} + {#if showClearAll} + + {/if} +
    + + {#each filteredSearchTerms as savedSearchTerm, i (i)} + {@const index = showClearAll ? i + 1 : i} +
    +
    + +
    handleSelect(savedSearchTerm)} + role="option" + tabindex="-1" + aria-selected={selectedIndex === index} + aria-label={savedSearchTerm} + > + + {savedSearchTerm} +
    +
    + handleClearSingle(savedSearchTerm)} + /> +
    +
    +
    + {/each} + {/if} +
    diff --git a/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte index 0f0e1ce4ed..a09747e4b2 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchLocationSection.svelte @@ -1,18 +1,13 @@
    - {$t('place')} + {$t('search_filter_location_description')} -
    +
    import { MediaType } from '$lib/constants'; - import RadioButton from '$lib/elements/RadioButton.svelte'; import { Text } from '@immich/ui'; import { t } from 'svelte-i18n'; + import { searchManager } from '$lib/managers/search-manager.svelte'; + import SearchButton from './SearchButton.svelte'; - interface Props { - filteredMedia: MediaType; - } - - let { filteredMedia = $bindable() }: Props = $props(); + let filteredMedia = $derived(searchManager.filter.mediaType);
    - {$t('media_type')} + {$t('media_type_description')} -
    - - - +
    + (searchManager.filter.mediaType = MediaType.All)} + > + {$t('all')} + + (searchManager.filter.mediaType = MediaType.Image)} + > + {$t('image')} + + (searchManager.filter.mediaType = MediaType.Video)} + > + {$t('video')} +
    diff --git a/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte index 4b39866187..ce2c0ca76c 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchPeopleSection.svelte @@ -1,65 +1,43 @@ {#await peoplePromise} @@ -72,23 +50,36 @@ ? filterPeople(people, name) : filterPeople(people, name).slice(0, numberOfPeople)} -
    -
    - {$t('people')} - -
    +
    + {$t('people_search_description')} + {#each peopleList as person (person.id)} {/each} @@ -97,6 +88,7 @@ {#if showAllPeople || people.length > peopleList.length}
    + {/if} + {/each} +
    + {/if} {/if} diff --git a/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte b/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte index e63ab5bcf2..928e68efce 100644 --- a/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte +++ b/web/src/lib/components/shared-components/search-bar/SearchTextSection.svelte @@ -1,70 +1,41 @@
    - {$t('search_type')} -
    + {$t('search_type_description')} +
    {#if featureFlagsManager.value.smartSearch} - + setType('smart')}> + {$t('context')} + {/if} - - - + setType('metadata')}> + {getSearchTypeTitle('metadata')} + + setType('description')}> + {getSearchTypeTitle('description')} + + setType('fullPath')}> + {getSearchTypeTitle('fullPath')} + {#if featureFlagsManager.value.ocr} - + setType('ocr')}> + {getSearchTypeTitle('ocr')} + {/if}
    - - {#if queryType === 'smart'} - - - - {:else if queryType === 'metadata'} - - - - {:else if queryType === 'description'} - - - - {:else if queryType === 'fullPath'} - - - - {:else if queryType === 'ocr'} - - - - {/if}
    diff --git a/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts new file mode 100644 index 0000000000..4dd99edbba --- /dev/null +++ b/web/src/lib/components/shared-components/search-bar/search-bar-utils.ts @@ -0,0 +1,157 @@ +import { getAllPeople, type PersonResponseDto, type TagResponseDto } from '@immich/sdk'; +import { DateTime } from 'luxon'; +import { t } from 'svelte-i18n'; +import type { SvelteSet } from 'svelte/reactivity'; +import { get } from 'svelte/store'; +import { MediaType } from '$lib/constants'; +import { handleError } from '$lib/utils/handle-error'; + +export enum SearchDatePreset { + ThisYear, + LastYear, + Last30Days, + Custom, +} + +export const getSearchDatePreset = (after: DateTime | undefined, before: DateTime | undefined) => { + if (!after && !before) { + return; + } + + const start = after?.toMillis(); + const end = before?.endOf('day').toMillis(); + + if (start === DateTime.utc().startOf('year').toMillis() && end === DateTime.utc().endOf('year').toMillis()) { + return SearchDatePreset.ThisYear; + } + + if ( + start === DateTime.utc().minus({ years: 1 }).startOf('year').toMillis() && + end === DateTime.utc().minus({ years: 1 }).endOf('year').toMillis() + ) { + return SearchDatePreset.LastYear; + } + + if ( + start === DateTime.utc().minus({ days: 30 }).startOf('day').toMillis() && + end === DateTime.utc().endOf('day').toMillis() + ) { + return SearchDatePreset.Last30Days; + } + + return SearchDatePreset.Custom; +}; + +export const getSearchDateRange = (after: DateTime | undefined, before: DateTime | undefined) => { + const $t = get(t); + const start = after?.toLocaleString(DateTime.DATE_MED); + const end = before?.toLocaleString(DateTime.DATE_MED); + return start && end ? $t('search_filter_date_interval', { values: { start, end } }) : (start ?? end); +}; + +export const getSearchDateTitle = ( + preset: SearchDatePreset | undefined, + before: DateTime | undefined, + after: DateTime | undefined, +): string | undefined => { + const $t = get(t); + switch (preset) { + case SearchDatePreset.ThisYear: { + return $t('search_filter_date_this_year'); + } + case SearchDatePreset.LastYear: { + return $t('search_filter_date_last_year'); + } + case SearchDatePreset.Last30Days: { + return $t('search_filter_date_last_30_days'); + } + case SearchDatePreset.Custom: { + return getSearchDateRange(before, after); + } + default: { + return; + } + } +}; + +export const getSearchTypeTitle = (type: string) => { + const $t = get(t); + switch (type) { + case 'metadata': { + return $t('file_name_text'); + } + case 'description': { + return $t('description'); + } + case 'fullPath': { + return $t('full_path_or_folder'); + } + case 'ocr': { + return $t('ocr'); + } + default: { + return; + } + } +}; + +export const getSearchPlacesTitle = (city?: string, state?: string, country?: string) => + [city, state, country].filter(Boolean).join(', ') || undefined; + +export const getSearchMediaTitle = (mediaType: MediaType) => { + const $t = get(t); + switch (mediaType) { + case MediaType.Image: { + return $t('image'); + } + case MediaType.Video: { + return $t('video'); + } + default: { + return; + } + } +}; + +export const getPeople = async (selected: SvelteSet): Promise => { + const $t = get(t); + try { + const res = await getAllPeople({ withHidden: false }); + res.people.sort((a, b) => (selected.has(a.id) ? -1 : selected.has(b.id) ? 1 : 0)); + return res.people; + } catch (error) { + handleError(error, $t('errors.failed_to_get_people')); + } + return []; +}; + +export const getSearchPeopleTitle = (people: PersonResponseDto[], selected: SvelteSet) => { + if (selected.size === 0) { + return; + } + + const $t = get(t); + + const name = people.find(({ id, name }) => name && selected.has(id))?.name; + if (name) { + return selected.size === 1 ? name : $t('name_plus_more_people', { values: { name, count: selected.size - 1 } }); + } + + return $t('people_count', { values: { count: selected.size } }); +}; + +export const getSearchTagsTitle = (tags: TagResponseDto[], selected: SvelteSet) => { + const $t = get(t); + + const id = selected.values().next().value; + if (!id) { + return undefined; + } + + const tag = tags.find((t) => t.id === id)?.name; + if (!tag) { + return undefined; + } + + return selected.size === 1 ? tag : $t('tag_plus_more_tags', { values: { tag, count: selected.size - 1 } }); +}; diff --git a/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte b/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte index da58d779e9..3948b6bbc2 100644 --- a/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte +++ b/web/src/lib/components/shared-components/settings/SystemConfigButtonRow.svelte @@ -1,15 +1,15 @@ + + + + {#await loadUsers()} +
    + +
    + {:then _} + {#if availableUsers.length > 0} +
    + {#each availableUsers as user (user.id)} + selectUser(user)} selected={selectedUsers.some(({ id }) => id === user.id)}> + +
    + {user.name} + {user.email} +
    +
    + {/each} +
    + + + + + {:else} + {$t('partner_page_no_more_users')} + {/if} + {/await} +
    +
    diff --git a/web/src/lib/modals/ClusterGroupUsersModal.svelte b/web/src/lib/modals/ClusterGroupUsersModal.svelte new file mode 100644 index 0000000000..2bfb61257c --- /dev/null +++ b/web/src/lib/modals/ClusterGroupUsersModal.svelte @@ -0,0 +1,54 @@ + + + + + {#await loadUsers()} +
    + +
    + {:then _} + {$t('cluster_group_invite_description')} + +
    + {$t('users')} +
    + +
    + {#each users as user (user.id)} +
    + +
    + {user.name} + {user.email} +
    +
    + {/each} +
    + {/await} +
    + + + + + + + +
    diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte deleted file mode 100644 index 2acb885d4a..0000000000 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ /dev/null @@ -1,225 +0,0 @@ - - - - -
    -
    - - - - - - - - - - - - - - - - - - - - {#if authManager.authenticated && authManager.preferences.ratings.enabled} - - {/if} - -
    - - - - - -
    -
    -
    -
    - - - - - - - -
    diff --git a/web/src/lib/modals/WorkflowLogsModal.svelte b/web/src/lib/modals/WorkflowLogsModal.svelte new file mode 100644 index 0000000000..b897d4b437 --- /dev/null +++ b/web/src/lib/modals/WorkflowLogsModal.svelte @@ -0,0 +1,171 @@ + + + + + {#if workflow.logging} + + + {$t('date')} + {$t('result')} + + + {#each entries as entry (entry.id)} + + + +

    + {DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, { + locale: $locale, + })} +

    + {#if entry.triggerDataId} + + + + {/if} +
    +
    + + + {#if entry.result === WorkflowResult.Completed} +

    + {$t('workflow_logging_completed')} +

    + {:else if entry.result === WorkflowResult.Halted} +

    + {#if entry.lastStep} + {$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('workflow_logging_halted')} + {/if} +

    + {:else} +

    + {#if entry.lastStep} + {$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })} + {:else} + {$t('error')} + {/if} +

    + {/if} +
    +
    +
    + {/each} + {#if hasNext} +
    ...
    + {/if} +
    +
    +
    +